From 37e602511fe93f43c6603a676243a1f90c1974e9 Mon Sep 17 00:00:00 2001 From: Chris Evernden Date: Wed, 29 Jul 2026 20:31:56 +1000 Subject: [PATCH 1/2] gh-43311: Add regression test for SSLError masking SMTPDataError in rset() gh-43311 (bpo-1481032, filed 2006) reported that when sendmail() gets a rejected DATA command and calls the automatic _rset() cleanup, an SSLError from the dead connection could mask the original SMTPDataError. This was already fixed as a side effect of two later, unrelated changes: gh-63696 (bpo-17498) routes all three sendmail() error paths through _rset(), and ssl.SSLError has subclassed OSError since Python 3.3, so send()/getreply() already normalize a dead-connection SSLError into SMTPServerDisconnected before _rset()'s own except-clause ever sees it. No regression test previously covered the TLS-specific variant of this scenario (the existing test__rest_from_mail_cmd is plaintext-only). Adds one, verified against a deliberately broken build to confirm it actually catches the original masking behavior (narrowing send()'s and getreply()'s OSError handlers reproduces the exact 2006 symptom: a ConnectionResetError leaks out and replaces the SMTPDataError). --- Lib/test/test_smtplib.py | 115 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/Lib/test/test_smtplib.py b/Lib/test/test_smtplib.py index b8aac8c20202a2..3226af4c3eb837 100644 --- a/Lib/test/test_smtplib.py +++ b/Lib/test/test_smtplib.py @@ -5,10 +5,12 @@ import email.utils import hashlib import hmac +import os import socket import smtplib import io import re +import struct import sys import time import select @@ -1400,6 +1402,119 @@ def test_lowercase_mail_from_rcpt_to(self): self.assertIn(['rcpt to:'], self.serv._SMTPchannel.all_received_lines) +SUPPORTS_SMTP_SSL = hasattr(smtplib, 'SMTP_SSL') +if SUPPORTS_SMTP_SSL: + import ssl + + CERTFILE = os.path.join( + os.path.dirname(__file__) or os.curdir, "certdata", "keycert3.pem") + + +@unittest.skipUnless(SUPPORTS_SMTP_SSL, 'SSL not supported') +class SMTPSSLRsetAfterDataErrorTests(unittest.TestCase): + # gh-43311 (bpo-1481032): a server that rejects DATA and then tears + # down the TLS connection used to have the automatic rset() inside + # sendmail()'s except-block raise its own error, masking the original + # SMTPDataError from the caller. Fixed as a side effect of gh-17498 + # (all three sendmail() error paths now go through _rset(), and + # ssl.SSLError has subclassed OSError since then, so send()/getreply() + # already normalize a dead-connection SSLError into + # SMTPServerDisconnected instead of leaking a raw ssl error). No + # regression test previously covered the TLS-specific case -- + # test__rest_from_mail_cmd above only exercises a plaintext + # disconnect. + + def setUp(self): + self.thread_key = threading_helper.threading_setup() + self.server_ready = threading.Event() + self.port_holder = [] + self.thread = threading.Thread(target=self._run_server, daemon=True) + self.thread.start() + self.server_ready.wait(support.LOOPBACK_TIMEOUT) + + def tearDown(self): + threading_helper.join_thread(self.thread) + threading_helper.threading_cleanup(*self.thread_key) + + def _run_server(self): + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.addCleanup(listener.close) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((HOST, 0)) + listener.listen(1) + self.port_holder.append(listener.getsockname()[1]) + self.server_ready.set() + listener.settimeout(support.LOOPBACK_TIMEOUT) + try: + raw_conn, _ = listener.accept() + except socket.timeout: + return + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(CERTFILE) + try: + conn = ctx.wrap_socket(raw_conn, server_side=True) + except ssl.SSLError: + return + try: + self._serve_one_rejected_transaction(conn) + except OSError: + pass + finally: + conn.close() + + def _serve_one_rejected_transaction(self, conn): + def read_line(): + data = b"" + while not data.endswith(b"\r\n"): + chunk = conn.recv(1) + if not chunk: + return data + data += chunk + return data + + conn.sendall(b"220 localhost testing TLS DATA rejection\r\n") + read_line() # EHLO/HELO + conn.sendall(b"250 localhost\r\n") + read_line() # MAIL FROM + conn.sendall(b"250 OK\r\n") + read_line() # RCPT TO + conn.sendall(b"250 OK\r\n") + read_line() # DATA + # Must accept with 354 here: a non-354 response makes data() + # raise SMTPDataError *directly*, before sendmail() ever reaches + # its own self._rset() call. The path this test targets only + # exists when the message body is accepted for transfer and the + # *final* response (after the end-of-data terminator) is the + # rejection -- that's the sendmail() branch that calls + # self._rset() before raising. + conn.sendall(b"354 go ahead\r\n") + received = b"" + while not received.endswith(b"\r\n.\r\n"): + chunk = conn.recv(4096) + if not chunk: + break + received += chunk + conn.sendall(b"554 Transaction failed\r\n") + # Tear the connection down hard (RST, not a clean TLS + # close_notify) so the client's automatic rset() -- called from + # sendmail()'s except-block for the SMTPDataError above -- hits a + # genuinely dead socket instead of a graceful close. + conn.setsockopt( + socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + + def test_original_error_not_masked_by_ssl_rset_failure(self): + port = self.port_holder[0] + client_ctx = ssl._create_unverified_context() + smtp = smtplib.SMTP_SSL( + HOST, port, context=client_ctx, timeout=support.LOOPBACK_TIMEOUT) + self.addCleanup(smtp.close) + with self.assertRaises(smtplib.SMTPDataError) as caught: + smtp.sendmail( + 'sender@example.com', ['recipient@example.com'], + 'Subject: hi\r\n\r\nbody') + self.assertEqual(caught.exception.smtp_code, 554) + + class SimSMTPUTF8Server(SimSMTPServer): def __init__(self, *args, **kw): From b589fba842c330a62d907736d3d15766c3a75ae4 Mon Sep 17 00:00:00 2001 From: Chris Evernden Date: Wed, 29 Jul 2026 20:50:07 +1000 Subject: [PATCH 2/2] gh-43311: Add NEWS entry --- .../next/Tests/2026-07-29-10-49-46.gh-issue-43311.tIDaHp.rst | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 Misc/NEWS.d/next/Tests/2026-07-29-10-49-46.gh-issue-43311.tIDaHp.rst diff --git a/Misc/NEWS.d/next/Tests/2026-07-29-10-49-46.gh-issue-43311.tIDaHp.rst b/Misc/NEWS.d/next/Tests/2026-07-29-10-49-46.gh-issue-43311.tIDaHp.rst new file mode 100644 index 00000000000000..a3ace6fc38e073 --- /dev/null +++ b/Misc/NEWS.d/next/Tests/2026-07-29-10-49-46.gh-issue-43311.tIDaHp.rst @@ -0,0 +1,4 @@ +Added a regression test for :mod:`smtplib` confirming that an error +raised while resetting the connection after a rejected ``DATA`` command +no longer masks the original :exc:`~smtplib.SMTPDataError` over a TLS +connection.