From 64462c6040da0ab40fd98c0c1b9e0cf457bf8292 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Sat, 30 May 2026 11:14:12 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B(smtp)=20fix=20opportunistic=20?= =?UTF-8?q?TLS=20against=20MXes=20with=20mismatched=20certs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "may" level was verifying the peer cert and falling back to cleartext on mismatch, which then bounced on STARTTLS-required servers (e.g. Mandrill's SES-backed inbound returns 530 to MAIL FROM in cleartext). Realigned on Postfix's documented behavior: - "may": opportunistic TLS, no cert verification. - "secure": mandatory TLS + CA chain + hostname check; defers if STARTTLS isn't advertised or handshake fails. - "encrypt" is dropped (replaced by "secure"). Also wires MTA_OUT_SMTP_TLS_SECURITY_LEVEL through both the direct and relay paths — it had been declared but never read — and collapses the four proxy_* kwargs + sender_hostname of send_smtp_mail into a single SmtpProxy dataclass. --- docs/env.md | 2 +- src/backend/core/mda/outbound.py | 1 + src/backend/core/mda/outbound_direct.py | 33 ++-- src/backend/core/mda/smtp.py | 161 +++++++++++--------- src/backend/core/tests/mda/test_outbound.py | 30 ++-- src/backend/core/tests/mda/test_smtp.py | 84 ++++++++-- src/backend/messages/settings.py | 14 +- 7 files changed, 210 insertions(+), 115 deletions(-) diff --git a/docs/env.md b/docs/env.md index f943baafd..03699c726 100644 --- a/docs/env.md +++ b/docs/env.md @@ -93,7 +93,7 @@ The application uses a new environment file structure with `.defaults` and `.loc | `MTA_OUT_RELAY_PASSWORD` | `pass` | Outbound SMTP password for relay mode | Optional | | `MTA_OUT_DIRECT_PROXIES` | `[]` | List of SOCKS proxy URLs (randomly chosen when non-empty; used in direct mode) | Optional | | `MTA_OUT_DIRECT_PORT` | `25` | TCP port for direct mode on remote MX servers | Optional | -| `MTA_OUT_SMTP_TLS_SECURITY_LEVEL` | `may` | SMTP TLS security level ("none", "may") | Optional | +| `MTA_OUT_SMTP_TLS_SECURITY_LEVEL` | `may` | SMTP TLS security level: `none`, `may` (opportunistic, no cert check, matches Postfix), or `secure` (mandatory TLS + CA chain + hostname check). Applied to both direct and relay modes — set to `secure` when running against a controlled relay with a valid cert. | Optional | | `MDA_API_SECRET` | `my-shared-secret-mda` | Shared secret for MDA API | Required | | `MDA_API_BASE_URL` | `http://backend-dev:8000/api/v1.0/` | Base URL for MDA API | Dev | diff --git a/src/backend/core/mda/outbound.py b/src/backend/core/mda/outbound.py index 3f5108156..a42128caa 100644 --- a/src/backend/core/mda/outbound.py +++ b/src/backend/core/mda/outbound.py @@ -754,6 +754,7 @@ def send_outbound_email( message_content=mime_data, smtp_username=mta_out_smtp_username, smtp_password=mta_out_smtp_password, + smtp_tls_security_level=settings.MTA_OUT_SMTP_TLS_SECURITY_LEVEL, ) return statuses diff --git a/src/backend/core/mda/outbound_direct.py b/src/backend/core/mda/outbound_direct.py index 73a128c25..b5511aa55 100644 --- a/src/backend/core/mda/outbound_direct.py +++ b/src/backend/core/mda/outbound_direct.py @@ -14,7 +14,7 @@ import dns.resolver -from core.mda.smtp import send_smtp_mail +from core.mda.smtp import SmtpProxy, send_smtp_mail logger = logging.getLogger(__name__) @@ -85,21 +85,19 @@ def group_recipients_by_mx(recipients: List[str]) -> Dict[str, Dict[str, Any]]: return domain_map -def select_smtp_proxy() -> Dict[str, Any]: - """ - Select an SMTP proxy to use for sending messages. - """ +def select_smtp_proxy() -> Optional[SmtpProxy]: + """Pick a SOCKS5 proxy at random from MTA_OUT_DIRECT_PROXIES, if any.""" if len(settings.MTA_OUT_DIRECT_PROXIES) > 0: - proxy = random.choice(settings.MTA_OUT_DIRECT_PROXIES) # noqa: S311 - parsed = urlparse(proxy) - return { - "proxy_host": parsed.hostname, - "proxy_port": parsed.port, - "proxy_username": parsed.username, - "proxy_password": parsed.password, - "sender_hostname": parsed.hostname, - } - return {} + url = random.choice(settings.MTA_OUT_DIRECT_PROXIES) # noqa: S311 + parsed = urlparse(url) + return SmtpProxy( + host=parsed.hostname, + port=parsed.port, + username=parsed.username, + password=parsed.password, + sender_hostname=parsed.hostname, + ) + return None def send_message_via_mx(envelope_from, recipient_emails, mime_data) -> Dict[str, Any]: @@ -151,8 +149,6 @@ def send_message_via_mx(envelope_from, recipient_emails, mime_data) -> Dict[str, remaining_recipients, ) - proxy_settings = select_smtp_proxy() - # Use direct SMTP, no auth smtp_statuses = send_smtp_mail( smtp_host=mx_hostname, @@ -161,7 +157,8 @@ def send_message_via_mx(envelope_from, recipient_emails, mime_data) -> Dict[str, envelope_from=envelope_from, recipient_emails=remaining_recipients.copy(), message_content=mime_data, - **proxy_settings, + smtp_tls_security_level=settings.MTA_OUT_SMTP_TLS_SECURITY_LEVEL, + proxy=select_smtp_proxy(), ) # Process results and update remaining recipients diff --git a/src/backend/core/mda/smtp.py b/src/backend/core/mda/smtp.py index 30635e90d..d14efd81d 100644 --- a/src/backend/core/mda/smtp.py +++ b/src/backend/core/mda/smtp.py @@ -3,10 +3,23 @@ import logging import smtplib import ssl +from dataclasses import dataclass from typing import Any, Dict, Optional import socks + +@dataclass(frozen=True) +class SmtpProxy: + """SOCKS5 proxy + EHLO identity to use when sending.""" + + host: str + port: int + username: Optional[str] = None + password: Optional[str] = None + sender_hostname: Optional[str] = None + + logger = logging.getLogger(__name__) @@ -78,6 +91,58 @@ def _get_socket(self, host, port, timeout): ) +def _build_tls_context(level: str) -> ssl.SSLContext: + """Build an SSL context matching Postfix's smtp_tls_security_level semantics. + + "secure" performs full PKI verification (CA chain + hostname). "may" creates + an unverified context: many public MXes serve mismatched or self-signed + certs, and rejecting them would just push delivery to cleartext anyway. + """ + ctx = ssl.create_default_context() + if level != "secure": + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +def _starttls_upgrade( + client: "ProxySMTP", level: str, sender_hostname: Optional[str] +) -> Optional[str]: + """Run the STARTTLS dance, with policy-aware fallback. + + Returns None on success (or when policy allows continuing in cleartext), + the sentinel ``"fallback"`` to ask the caller to retry the whole session + without TLS (``may`` only), or an error string to defer delivery. + """ + if level == "none": + return None + if not client.has_extn("starttls"): + if level == "secure": + return ( + "STARTTLS not advertised by server " + "(required by smtp_tls_security_level=secure)" + ) + return None + try: + code, msg = client.starttls(context=_build_tls_context(level)) + logger.debug("SMTP: STARTTLS response: %s %s", code, msg) + if not 200 <= code <= 299: + raise RuntimeError(f"STARTTLS rejected: {code} {msg}") + code, msg = client.ehlo(sender_hostname) + logger.debug("SMTP: EHLO2 response: %s %s", code, msg) + if not 200 <= code <= 299: + raise RuntimeError(f"EHLO after STARTTLS failed: {code} {msg}") + return None + except Exception as e: # pylint: disable=broad-exception-caught + if level == "may": + logger.warning( + "SMTP: STARTTLS failed: %s, falling back to unencrypted socket", e + ) + return "fallback" + logger.error("SMTP: Failed to send email with TLS: %s", e, exc_info=True) + return "Failed to send email with TLS" + + # pylint: disable=too-many-arguments def send_smtp_mail( smtp_host: str, @@ -88,11 +153,7 @@ def send_smtp_mail( smtp_username: Optional[str] = None, smtp_password: Optional[str] = None, timeout: int = 60, - proxy_host: Optional[str] = None, - proxy_port: Optional[int] = None, - proxy_username: Optional[str] = None, - proxy_password: Optional[str] = None, - sender_hostname: Optional[str] = None, + proxy: Optional[SmtpProxy] = None, smtp_ip: Optional[str] = None, smtp_tls_security_level: Optional[str] = "may", ) -> Dict[str, Any]: @@ -109,12 +170,8 @@ def send_smtp_mail( smtp_username: SMTP username (optional) smtp_password: SMTP password (optional) timeout: Connection timeout in seconds - proxy_host: SOCKS5 proxy hostname - proxy_port: SOCKS5 proxy port - proxy_username: SOCKS5 proxy username - proxy_password: SOCKS5 proxy password - sender_hostname: Local hostname to use for SMTP EHLO/HELO - smtp_tls_security_level: SMTP TLS security level ("none", "may") + proxy: Optional SOCKS5 proxy and local hostname to present in EHLO/HELO + smtp_tls_security_level: SMTP TLS security level ("none", "may", "secure") Returns: Dict mapping recipient emails to delivery status with retry flag: @@ -127,6 +184,8 @@ def send_smtp_mail( } """ statuses = {} + sender_hostname = proxy.sender_hostname if proxy else None + proxy_host = proxy.host if proxy else None def error_for_all_recipients(error: str, retry: bool) -> Dict[str, Any]: return { @@ -145,9 +204,9 @@ def error_for_all_recipients(error: str, retry: bool) -> Dict[str, Any]: port=None, timeout=timeout, proxy_host=proxy_host, - proxy_port=proxy_port, - proxy_username=proxy_username, - proxy_password=proxy_password, + proxy_port=proxy.port if proxy else None, + proxy_username=proxy.username if proxy else None, + proxy_password=proxy.password if proxy else None, local_hostname=sender_hostname, ) @@ -180,61 +239,25 @@ def _quit(): _quit() return error_for_all_recipients(f"HELO failed: {code} {msg}", True) - if client.has_extn("starttls") and smtp_tls_security_level != "none": - try: - # smtplib.SMTP.starttls() doesn't validate certificates by default! - # https://github.com/python/cpython/issues/91826 - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = True - ssl_context.verify_mode = ssl.CERT_REQUIRED - (code, msg) = client.starttls(context=ssl_context) - logger.debug("SMTP: STARTTLS response: %s %s", code, msg) - if not 200 <= code <= 299: - _quit() - if smtp_tls_security_level == "may": - raise Exception(f"STARTTLS failed : {code} {msg}") # pylint: disable=broad-exception-raised - return error_for_all_recipients( - f"STARTTLS failed: {code} {msg}", True - ) - - # Restart the SMTP session now that we're in TLS mode - (code, msg) = client.ehlo(sender_hostname) - logger.debug("SMTP: EHLO2 response: %s %s", code, msg) - if not 200 <= code <= 299: - _quit() - if smtp_tls_security_level == "may": - raise Exception(f"STARTTLS failed : {code} {msg}") # pylint: disable=broad-exception-raised - return error_for_all_recipients( - f"EHLO after STARTTLS failed: {code} {msg}", True - ) - except Exception as e: # pylint: disable=broad-exception-caught - if smtp_tls_security_level == "may": - logger.warning( - "SMTP: STARTTLS failed: %s, falling back to unencrypted socket", - e, - ) - return send_smtp_mail( - smtp_host=smtp_host, - smtp_ip=smtp_ip, - smtp_port=smtp_port, - envelope_from=envelope_from, - recipient_emails=recipient_emails, - message_content=message_content, - smtp_username=smtp_username, - smtp_password=smtp_password, - timeout=timeout, - proxy_host=proxy_host, - proxy_port=proxy_port, - proxy_username=proxy_username, - proxy_password=proxy_password, - sender_hostname=sender_hostname, - smtp_tls_security_level="none", - ) - logger.error( - "SMTP: Failed to send email with TLS: %s", e, exc_info=True - ) - _quit() - return error_for_all_recipients("Failed to send email with TLS", True) + tls_result = _starttls_upgrade(client, smtp_tls_security_level, sender_hostname) + if tls_result == "fallback": + _quit() + return send_smtp_mail( + smtp_host=smtp_host, + smtp_ip=smtp_ip, + smtp_port=smtp_port, + envelope_from=envelope_from, + recipient_emails=recipient_emails, + message_content=message_content, + smtp_username=smtp_username, + smtp_password=smtp_password, + timeout=timeout, + proxy=proxy, + smtp_tls_security_level="none", + ) + if tls_result is not None: + _quit() + return error_for_all_recipients(tls_result, True) if smtp_username and smtp_password: try: diff --git a/src/backend/core/tests/mda/test_outbound.py b/src/backend/core/tests/mda/test_outbound.py index 76eadc22d..aee7e44e7 100644 --- a/src/backend/core/tests/mda/test_outbound.py +++ b/src/backend/core/tests/mda/test_outbound.py @@ -16,6 +16,7 @@ from core import enums, factories, models from core.mda import outbound from core.mda.signing import generate_dkim_key, sign_message_dkim +from core.mda.smtp import SmtpProxy SCHEMA_CUSTOM_ATTRIBUTES = { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -347,6 +348,14 @@ def resolve_return_value(domain, record_type, **kwargs): sorted_calls = sorted(mock_smtp_send.mock_calls, key=lambda x: x[2]["smtp_ip"]) + expected_proxy = SmtpProxy( + host="smtp.proxy", + port=1080, + username="proxyuser", + password="proxyuser", + sender_hostname="smtp.proxy", + ) + # Check first call - to@example.com, cc@example.com, cc2@example.com to mx1.example.com assert sorted_calls[0] == call( smtp_host="mx1.example.com", @@ -355,11 +364,8 @@ def resolve_return_value(domain, record_type, **kwargs): envelope_from=draft_message.sender.email, recipient_emails={"to@example.com", "cc@example.com", "cc2@example.com"}, message_content=draft_message.blob.get_content(), - proxy_host="smtp.proxy", - proxy_port=1080, - proxy_username="proxyuser", - proxy_password="proxyuser", - sender_hostname="smtp.proxy", + smtp_tls_security_level="may", + proxy=expected_proxy, ) # Check second call - cc@example.com, to@example.com retry to mx2.example.com @@ -370,11 +376,8 @@ def resolve_return_value(domain, record_type, **kwargs): envelope_from=draft_message.sender.email, recipient_emails={"cc@example.com", "to@example.com"}, message_content=draft_message.blob.get_content(), - proxy_host="smtp.proxy", - proxy_port=1080, - proxy_username="proxyuser", - proxy_password="proxyuser", - sender_hostname="smtp.proxy", + smtp_tls_security_level="may", + proxy=expected_proxy, ) # Check third call - bcc@example2.com to mx1.example2.com @@ -385,11 +388,8 @@ def resolve_return_value(domain, record_type, **kwargs): envelope_from=draft_message.sender.email, recipient_emails={"bcc@example2.com"}, message_content=draft_message.blob.get_content(), - proxy_host="smtp.proxy", - proxy_port=1080, - proxy_username="proxyuser", - proxy_password="proxyuser", - sender_hostname="smtp.proxy", + smtp_tls_security_level="may", + proxy=expected_proxy, ) @patch("core.mda.outbound_direct.dns.resolver.resolve") diff --git a/src/backend/core/tests/mda/test_smtp.py b/src/backend/core/tests/mda/test_smtp.py index 2a868d5e6..cd3fef1ed 100644 --- a/src/backend/core/tests/mda/test_smtp.py +++ b/src/backend/core/tests/mda/test_smtp.py @@ -7,7 +7,7 @@ import pytest -from core.mda.smtp import send_smtp_mail +from core.mda.smtp import SmtpProxy, send_smtp_mail logger = logging.getLogger(__name__) @@ -21,6 +21,8 @@ def __init__(self): self.mail_from_response = None # Configure MAIL FROM response self.data_response = None # Configure DATA command response self.ehlo_sleep = None # Configure EHLO timeout + self.advertise_starttls = False # Add STARTTLS to EHLO extensions + self.starttls_break_handshake = False # Reply 220 then close socket self.server_socket = None self.server_thread = None self.running = False @@ -42,7 +44,7 @@ def configure_ehlo_sleep(self, sleep_time: int): """Configure EHLO sleep time.""" self.ehlo_sleep = sleep_time - def start(self): + def start(self): # pylint: disable=too-many-statements """Start the SMTP server.""" self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -51,7 +53,7 @@ def start(self): self.server_socket.listen(1) self.running = True - def handle_client(client_socket): + def handle_client(client_socket): # pylint: disable=too-many-branches,too-many-statements try: # Send welcome message client_socket.send(b"220 Test SMTP Server\r\n") @@ -68,7 +70,18 @@ def handle_client(client_socket): if command in {"EHLO", "HELO"}: if self.ehlo_sleep: time.sleep(self.ehlo_sleep) - client_socket.send(b"250 OK\r\n") + if self.advertise_starttls: + client_socket.send( + b"250-mock.example\r\n250-STARTTLS\r\n250 OK\r\n" + ) + else: + client_socket.send(b"250 OK\r\n") + elif command == "STARTTLS": + client_socket.send(b"220 Ready to start TLS\r\n") + if self.starttls_break_handshake: + # Don't speak TLS — client handshake will fail. + client_socket.close() + break elif command == "MAIL" and rest.upper().startswith("FROM:"): rest = rest[5:].strip() if self.mail_from_response: @@ -249,7 +262,8 @@ def test_timeout_delivery(self): smtp_handler.stop() def test_mixed_recipient_responses(self): - """Test mixed delivery scenarios using a real SMTP server that returns different responses.""" + """Test mixed delivery scenarios using a real SMTP server with + different responses per recipient.""" # Create and start the custom SMTP server smtp_handler = MixedResponseSMTPHandler() smtp_handler.configure_recipient_response( @@ -340,10 +354,12 @@ def test_proxy_parameters(self): recipient_emails=recipients, message_content=message, timeout=1, - proxy_host="proxy.example.com", - proxy_port=1080, - proxy_username="proxyuser", - proxy_password="proxypass", + proxy=SmtpProxy( + host="proxy.example.com", + port=1080, + username="proxyuser", + password="proxypass", + ), ) assert len(result) == 1 @@ -385,6 +401,56 @@ def test_mail_from_failure(self): finally: smtp_handler.stop() + def test_secure_defers_when_starttls_not_advertised(self): + """At smtp_tls_security_level=secure, a server that doesn't advertise + STARTTLS must cause delivery to defer rather than fall through to + cleartext.""" + smtp_handler = MixedResponseSMTPHandler() + smtp_handler.start() + + try: + time.sleep(0.1) + result = send_smtp_mail( + smtp_host="127.0.0.1", + smtp_port=smtp_handler.port, + envelope_from="sender@example.com", + recipient_emails={"user1@example.com"}, + message_content=b"Subject: Test\n\nHello", + timeout=5, + smtp_tls_security_level="secure", + ) + + assert result["user1@example.com"]["delivered"] is False + assert result["user1@example.com"]["retry"] is True + assert "STARTTLS" in result["user1@example.com"]["error"] + finally: + smtp_handler.stop() + + def test_may_falls_back_when_starttls_handshake_fails(self): + """At smtp_tls_security_level=may, a TLS handshake failure (e.g. cert + mismatch) must transparently fall back to cleartext and deliver. + Reproduces the Mandrill/SES regression that motivated this code path.""" + smtp_handler = MixedResponseSMTPHandler() + smtp_handler.advertise_starttls = True + smtp_handler.starttls_break_handshake = True + smtp_handler.start() + + try: + time.sleep(0.1) + result = send_smtp_mail( + smtp_host="127.0.0.1", + smtp_port=smtp_handler.port, + envelope_from="sender@example.com", + recipient_emails={"user1@example.com"}, + message_content=b"Subject: Test\n\nHello", + timeout=5, + smtp_tls_security_level="may", + ) + + assert result["user1@example.com"]["delivered"] is True + finally: + smtp_handler.stop() + def test_data_failure(self): """Test DATA command failure handling.""" # Create and start the custom SMTP server diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index 039a60a02..45582e5b6 100644 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -445,8 +445,16 @@ class Base(Configuration): None, environ_name="MTA_OUT_RELAY_PASSWORD", environ_prefix=None ) - # SMTP settings for both modes - # We support a subset of https://www.postfix.org/postconf.5.html#smtp_tls_security_level + # SMTP TLS policy for both direct (MX) and relay paths. Subset of + # https://www.postfix.org/postconf.5.html#smtp_tls_security_level : + # - "none" : never attempt STARTTLS. + # - "may" : opportunistic TLS, no cert verification (Postfix-aligned). + # Suitable default for direct MX delivery, where many public + # MXes serve mismatched or self-signed certs. + # - "secure": mandatory TLS + CA chain + hostname check; defers on + # failure. Use this when running against a controlled relay + # with a valid cert (SMTP AUTH credentials are sent inside + # the TLS tunnel, so an unverified peer is a MITM risk). MTA_OUT_SMTP_TLS_SECURITY_LEVEL = values.Value( "may", environ_name="MTA_OUT_SMTP_TLS_SECURITY_LEVEL", environ_prefix=None ) @@ -1219,7 +1227,7 @@ def __init__(self, *args, **kwargs): self.MTA_OUT_RELAY_USERNAME = os.environ.get("MTA_OUT_SMTP_USERNAME") self.MTA_OUT_RELAY_PASSWORD = os.environ.get("MTA_OUT_SMTP_PASSWORD") - if self.MTA_OUT_SMTP_TLS_SECURITY_LEVEL not in {"none", "may", "encrypt"}: + if self.MTA_OUT_SMTP_TLS_SECURITY_LEVEL not in {"none", "may", "secure"}: raise ValueError( f"Invalid MTA_OUT_SMTP_TLS_SECURITY_LEVEL: {self.MTA_OUT_SMTP_TLS_SECURITY_LEVEL}" ) From daeb3a9127199a99d4b6e693cbd9b6219199c32d Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Sat, 30 May 2026 11:31:28 +0200 Subject: [PATCH 2/3] review fixes --- src/backend/core/tests/mda/test_outbound.py | 3 +++ src/backend/core/tests/mda/test_smtp.py | 24 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/backend/core/tests/mda/test_outbound.py b/src/backend/core/tests/mda/test_outbound.py index aee7e44e7..f7376462e 100644 --- a/src/backend/core/tests/mda/test_outbound.py +++ b/src/backend/core/tests/mda/test_outbound.py @@ -206,6 +206,7 @@ def test_outbound_send_relay(self, mock_smtp_send, draft_message): message_content=draft_message.blob.get_content(), smtp_username="smtp_user", smtp_password="smtp_pass", + smtp_tls_security_level="may", ) # Check message object updated @@ -431,6 +432,8 @@ def smtp_return_value(*args, **kwargs): envelope_from=draft_message.sender.email, recipient_emails={"bcc@example2.com"}, message_content=draft_message.blob.get_content(), + smtp_tls_security_level="may", + proxy=None, ) # Check message object updated diff --git a/src/backend/core/tests/mda/test_smtp.py b/src/backend/core/tests/mda/test_smtp.py index cd3fef1ed..8b928d390 100644 --- a/src/backend/core/tests/mda/test_smtp.py +++ b/src/backend/core/tests/mda/test_smtp.py @@ -451,6 +451,30 @@ def test_may_falls_back_when_starttls_handshake_fails(self): finally: smtp_handler.stop() + def test_secure_fails_on_starttls_handshake_failure(self): + """At smtp_tls_security_level=secure, a STARTTLS handshake failure must + defer delivery rather than fall through to cleartext.""" + smtp_handler = MixedResponseSMTPHandler() + smtp_handler.advertise_starttls = True + smtp_handler.starttls_break_handshake = True + smtp_handler.start() + + try: + time.sleep(0.1) + result = send_smtp_mail( + smtp_host="127.0.0.1", + smtp_port=smtp_handler.port, + envelope_from="sender@example.com", + recipient_emails={"user1@example.com"}, + message_content=b"Subject: Test\n\nHello", + timeout=5, + smtp_tls_security_level="secure", + ) + + assert result["user1@example.com"]["delivered"] is False + finally: + smtp_handler.stop() + def test_data_failure(self): """Test DATA command failure handling.""" # Create and start the custom SMTP server From 93f2de68a38c9b6d6bc6d522813968363255ab4e Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Sat, 30 May 2026 12:42:40 +0200 Subject: [PATCH 3/3] fix review --- src/backend/core/tests/mda/test_smtp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/core/tests/mda/test_smtp.py b/src/backend/core/tests/mda/test_smtp.py index 8b928d390..80690a9a8 100644 --- a/src/backend/core/tests/mda/test_smtp.py +++ b/src/backend/core/tests/mda/test_smtp.py @@ -472,6 +472,7 @@ def test_secure_fails_on_starttls_handshake_failure(self): ) assert result["user1@example.com"]["delivered"] is False + assert result["user1@example.com"]["retry"] is True finally: smtp_handler.stop()