SMTP port 465 implicit TLS support in email adapter
NousResearch/hermes-agent · cluster 11842 · gateway
Data as of
Nothing open on GitHub. Fix merged in #46084. Check it covers your case.
The short answer
- Known?
- Yes. 8 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #46084. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #46084. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and pull requests concern the email gateway's lack of support for implicit TLS (SMTP_SSL) on port 465, where it incorrectly uses STARTTLS instead. Several entries also address related concerns such as IPv4 fallback and IPv6 timeout handling.
Open issues0
Open PRs0
Fixes on record
-
#46084 fix(email): use implicit TLS for port 465
files:
gateway/platforms/email.py,tests/gateway/test_email.pyShow the diff
-
gateway/platforms/email.py@@ -22,6 +22,7 @@ import os import re import smtplib +import socket import ssl import uuid from email.header import decode_header @@ -62,6 +63,63 @@ # Gmail-safe max length per email body MAX_MESSAGE_LENGTH = 50_000 +SMTP_CONNECT_TIMEOUT = 30 + + +def _create_ipv4_connection( + host: str, + port: int, + timeout: float, + source_address: Any = None, +) -> socket.socket: + """Create a TCP connection using only IPv4 addresses. + + This mirrors ``socket.create_connection`` but constrains DNS resolution to + ``AF_INET``. It avoids mutating process-global socket functions, which + matters because email sends run in executor threads. + """ + last_error: OSError | None = None + for family, socktype, proto, _canonname, sockaddr in socket.getaddrinfo( + host, port, socket.AF_INET, socket.SOCK_STREAM + ): + sock = socket.socket(family, socktype, proto) + sock.settimeout(timeout) + try: + if source_address: + sock.bind(source_address) + sock.connect(sockaddr) + return sock + except OSError as exc: + last_error = exc + sock.close() + if last_error is not None: + raise last_error + raise OSError(f"No IPv4 address found for {host}:{port}") + + +class _IPv4SMTP(smtplib.SMTP): + def _get_socket(self, host, port, timeout): # type: ignore[override] + return _create_ipv4_connection( + host, + port, + timeout, + source_address=self.source_address, + ) + + +class _IPv4SMTP_SSL(smtplib.SMTP_SSL): + def _get_socket(self, host, port, timeout): # type: ignore[override] + raw_sock = _create_ipv4_connection( + host, + port, + timeout, + source_address=self.source_address, + ) + return self.context.wrap_socket( + raw_sock, + server_hostname=getattr(self, "_host", host), + ) + # Supported image extensions for inline detection _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} @@ -293,6 +351,48 @@ def _trim_seen_uids(self) -> None: # Fallback: just clear old entries if sort fails self._seen_uids = set(list(self._seen_uids)[-self._seen_uids_max // 2:]) + def _connect_smtp(self) -> smtplib.SMTP: + """Create an SMTP connection, selecting the correct protocol for the port. + + Port 465 uses implicit TLS (``SMTP_SSL``). All other ports use + ``SMTP`` + ``STARTTLS``. + + When the host resolves to an IPv6 address that is unreachable + (common on networks without IPv6 routing), the default connection can + hang until the socket timeout expires. We retry connection-level + failures through an IPv4-only socket path, without mutating global + resolver state. TLS verification errors are not retried. + + Returns a connected SMTP object with TLS established — callers + can proceed directly to ``login()``. + """ + ctx = ssl.create_default_context() + host = self._smtp_host + port = self._smtp_port + + def _connect(*, ipv4_only: bool = False) -> smtplib.SMTP: + """Attempt one SMTP connection.""" + smtp_cls = _IPv4SMTP if ipv4_only else smtplib.SMTP + smtp_ssl_cls = _IPv4SMTP_SSL if ipv4_only else smtplib.SMTP_SSL + if port == 465: + return smtp_ssl_cls(host, port, timeout=SMTP_CONNECT_TIMEOUT, context=ctx) + smtp = smtp_cls(host, port, timeout=SMTP_CONNECT_TIMEOUT) + try: + smtp.starttls(context=ctx) + except Exception: + smtp.close() + raise + return smtp + + try: + return _connect() + except (socket.timeout, TimeoutError, ConnectionError, OSError) as exc: + if isinstance(exc, ssl.SSLError): + raise + # Connection-level failure (may be unreachable IPv6). + # Retry with IPv4 only. + return _connect(ipv4_only=True) + async def connect(self) -> bool: """Connect to the IMAP server and start polling for new messages.""" try: @@ -316,10 +416,11 @@ async def connect(self) -> bool: try: # Test SMTP connection - smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) - smtp.starttls(context=ssl.create_default_context()) - smtp.login(self._address, self._password) - smtp.quit() + smtp = self._connect_smtp() + try: + smtp.login(self._address, self._password) + finally: + smtp.quit() logger.info("[Email] SMTP connection test passed.") except Exception as e: logger.error("[Email] SMTP connection failed: %s", e) @@ -555,9 +656,8 @@ def _send_email( msg.attach(MIMEText(body, "plain", "utf-8")) - smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) + smtp = self._connect_smtp() try: - smtp.starttls(context=ssl.create_default_context()) smtp.login(self._address, self._password) smtp.send_message(msg) finally: @@ -677,9 +777,8 @@ def _send_email_with_attachments( except Exception as e: logger.warning("[Email] Failed to attach %s: %s", file_path, e) - smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) + smtp = self._connect_smtp() try: - smtp.starttls(context=ssl.create_default_context()) smtp.login(self._address, self._password) smtp.send_message(msg) finally: @@ -756,9 +855,8 @@ def _send_email_with_attachment( part.add_header("Content-Disposition", f"attachment; filename={fname}") msg.attach(part) - smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) + smtp = self._connect_smtp() try: - smtp.starttls(context=ssl.create_default_context()) smtp.login(self._address, self._password) smtp.send_message(msg) finally: -
tests/gateway/test_email.py@@ -18,7 +18,7 @@ from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase from email import encoders -from unittest.mock import patch, MagicMock, AsyncMock +from unittest.mock import patch, MagicMock, AsyncMock, ANY from gateway.platforms.base import SendResult @@ -1265,5 +1265,123 @@ def test_send_imap_id_swallows_errors_for_non_supporting_servers(self): mock_imap.xatom.assert_called_once() +class TestConnectSmtp(unittest.TestCase): + """Test _connect_smtp() helper: protocol selection and IPv6 fallback.""" + + def _make_adapter(self, port="587"): + from gateway.config import PlatformConfig + with patch.dict(os.environ, { + "EMAIL_ADDRESS": "[redacted:email]", + "EMAIL_PASSWORD": "secret", + "EMAIL_IMAP_HOST": "imap.test.com", + "EMAIL_SMTP_HOST": "smtp.test.com", + "EMAIL_SMTP_PORT": port, + }): + from gateway.platforms.email import EmailAdapter + return EmailAdapter(PlatformConfig(enabled=True)) + + def test_port_587_uses_smtp_with_starttls(self): + """Port 587 should use smtplib.SMTP + STARTTLS.""" + adapter = self._make_adapter("587") + + with patch("smtplib.SMTP") as mock_smtp, \ + patch("smtplib.SMTP_SSL") as mock_smtp_ssl: + mock_server = MagicMock() + mock_smtp.return_value = mock_server + + result = adapter._connect_smtp() + + mock_smtp.assert_called_once() + mock_smtp_ssl.assert_not_called() + mock_server.starttls.assert_called_once() + self.assertIs(result, mock_server) + + def test_port_465_uses_smtp_ssl(self): + """Port 465 should use smtplib.SMTP_SSL (implicit TLS).""" + adapter = self._make_adapter("465") + + with patch("smtplib.SMTP") as mock_smtp, \ + patch("smtplib.SMTP_SSL") as mock_smtp_ssl: + mock_server = MagicMock() + mock_smtp_ssl.return_value = mock_server + + result = adapter._connect_smtp() + + mock_smtp_ssl.assert_called_once() + mock_smtp.assert_not_called() + self.assertIs(result, mock_server) + + def test_ipv6_timeout_falls_back_to_ipv4(self): + """When default connection times out, retry with an IPv4-only SMTP path.""" + import socket as _socket + from gateway.platforms import email as email_mod + + adapter = self._make_adapter("587") + + with patch("smtplib.SMTP", side_effect=_socket.timeout("timed out")), \ + patch.object(email_mod, "_IPv4SMTP") as mock_ipv4_smtp: + mock_server = MagicMock() + mock_ipv4_smtp.return_value = mock_server + + result = adapter._connect_smtp() + + self.assertIs(result, mock_server) + mock_ipv4_smtp.assert_called_once_with("smtp.test.com", 587, timeout=30) + mock_server.starttls.assert_called_once() + + def test_port_465_ipv6_fallback(self): + """Port 465 IPv6 timeout falls back to IPv4 with SMTP_SSL.""" + import socket as _socket + from gateway.platforms import email as email_mod + + adapter = self._make_adapter("465") + + with patch("smtplib.SMTP_SSL", side_effect=_socket.timeout("timed out")), \ + patch.object(email_mod, "_IPv4SMTP_SSL") as mock_ipv4_smtp_ssl: + mock_server = MagicMock() + mock_ipv4_smtp_ssl.return_value = mock_server + + result = adapter._connect_smtp() + + self.assertIs(result, mock_server) + mock_ipv4_smtp_ssl.assert_called_once_with( + "smtp.test.com", 465, timeout=30, context=ANY, + ) + + def test_tls_verification_error_does_not_retry_ipv4(self): + """Certificate failures are security errors, not IPv6 reachability failures.""" + import ssl as _ssl + from gateway.platforms import email as email_mod + + adapter = self._make_adapter("465") + + with patch("smtplib.SMTP_SSL", side_effect=_ssl.SSLError("cert verify failed")), \ + patch.object(email_mod, "_IPv4SMTP_SSL") as mock_ipv4_smtp_ssl: + with self.assertRaises(_ssl.SSLError): + adapter._connect_smtp() + + mock_ipv4_smtp_ssl.assert_not_called() + + def test_ipv4_connection_does_not_mutate_global_resolver(self): + """IPv4 fallback must not monkeypatch process-global socket state.""" + import socket as _socket + from gateway.platforms.email import _create_ipv4_connection + + original_getaddrinfo = _socket.getaddrinfo + fake_sock = MagicMock() + + with patch( + "socket.getaddrinfo", + return_value=[(_socket.AF_INET, _socket.SOCK_STREAM, 6, "", ("192.0.2.1", 587))], + ) as mock_getaddrinfo, patch("socket.socket", return_value=fake_sock): + result = _create_ipv4_connection("smtp.test.com", 587, 30) + + self.assertIs(result, fake_sock) + mock_getaddrinfo.assert_called_once_with( + "smtp.test.com", 587, _socket.AF_INET, _socket.SOCK_STREAM, + ) + self.assertIs(_socket.getaddrinfo, original_getaddrinfo) + + if __name__ == "__main__": unittest.main()
-
4 PRs closed without merging
From the close comments: done elsewhere 1 · duplicate 1 · merged via another change 1 · other 1
- #12161 fix(email): add email SMTP STARTTLS fallback · other
-
#27680 fix(email): use implicit TLS for SMTPS port 465 · done elsewhere →
#41112 -
#46019 fix: use SMTP_SSL for port 465 and force IPv4 in email adapter · duplicate →
#46084 -
#46061 fix(email): use SMTP_SSL for port 465 and fall back to IPv4 on timeout · merged via another change →
#46084
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.20: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #46084
- Closed issues
- 3
- Attempts
- 4 PRs closed without merging
- Fix link checked
- 14 Jun 2026