{"repo":"NousResearch/hermes-agent","public_id":"11842","display_label":"SMTP port 465 implicit TLS support in email adapter","summary":"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.","subsystem":"gateway","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":46084},"resolution":{"state":"partially_resolved","by":"pr-46084"},"freshness":{"state":"verified","last_verified_at":"2026-06-14T11:16:27+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.20428571428571426},"fix_edges":[{"issue_number":46018,"pr_number":46084,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":11842,"title":"[Bug]: Email gateway fails on SMTP port 465 — uses STARTTLS instead of SMTP_SSL","url":null,"state":"closed"},{"kind":"issue","number":19152,"title":"[Feature]: mail gateway does not support implicit TLS SMTP on port 465","url":null,"state":"closed"},{"kind":"issue","number":46018,"title":"fix: Email adapter fails on port 465 (SMTP_SSL) and hangs on IPv6","url":null,"state":"closed"},{"kind":"pr","number":12161,"title":"fix(email): add email SMTP STARTTLS fallback","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":27680,"title":"fix(email): use implicit TLS for SMTPS port 465","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41112","number":41112}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":46019,"title":"fix: use SMTP_SSL for port 465 and force IPv4 in email adapter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#46084","number":46084}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":46061,"title":"fix(email): use SMTP_SSL for port 465 and fall back to IPv4 on timeout","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#46084","number":46084}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":46084,"title":"fix(email): use implicit TLS for port 465","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/email.py","tests/gateway/test_email.py"],"fix_hunks":[{"file":"gateway/platforms/email.py","hunks":["@@ -22,6 +22,7 @@\n import os\n import re\n import smtplib\n+import socket\n import ssl\n import uuid\n from email.header import decode_header","@@ -62,6 +63,63 @@\n # Gmail-safe max length per email body\n MAX_MESSAGE_LENGTH = 50_000\n \n+SMTP_CONNECT_TIMEOUT = 30\n+\n+\n+def _create_ipv4_connection(\n+    host: str,\n+    port: int,\n+    timeout: float,\n+    source_address: Any = None,\n+) -> socket.socket:\n+    \"\"\"Create a TCP connection using only IPv4 addresses.\n+\n+    This mirrors ``socket.create_connection`` but constrains DNS resolution to\n+    ``AF_INET``.  It avoids mutating process-global socket functions, which\n+    matters because email sends run in executor threads.\n+    \"\"\"\n+    last_error: OSError | None = None\n+    for family, socktype, proto, _canonname, sockaddr in socket.getaddrinfo(\n+        host, port, socket.AF_INET, socket.SOCK_STREAM\n+    ):\n+        sock = socket.socket(family, socktype, proto)\n+        sock.settimeout(timeout)\n+        try:\n+            if source_address:\n+                sock.bind(source_address)\n+            sock.connect(sockaddr)\n+            return sock\n+        except OSError as exc:\n+            last_error = exc\n+            sock.close()\n+    if last_error is not None:\n+        raise last_error\n+    raise OSError(f\"No IPv4 address found for {host}:{port}\")\n+\n+\n+class _IPv4SMTP(smtplib.SMTP):\n+    def _get_socket(self, host, port, timeout):  # type: ignore[override]\n+        return _create_ipv4_connection(\n+            host,\n+            port,\n+            timeout,\n+            source_address=self.source_address,\n+        )\n+\n+\n+class _IPv4SMTP_SSL(smtplib.SMTP_SSL):\n+    def _get_socket(self, host, port, timeout):  # type: ignore[override]\n+        raw_sock = _create_ipv4_connection(\n+            host,\n+            port,\n+            timeout,\n+            source_address=self.source_address,\n+        )\n+        return self.context.wrap_socket(\n+            raw_sock,\n+            server_hostname=getattr(self, \"_host\", host),\n+        )\n+\n # Supported image extensions for inline detection\n _IMAGE_EXTS = {\".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\"}\n ","@@ -293,6 +351,48 @@ def _trim_seen_uids(self) -> None:\n             # Fallback: just clear old entries if sort fails\n             self._seen_uids = set(list(self._seen_uids)[-self._seen_uids_max // 2:])\n \n+    def _connect_smtp(self) -> smtplib.SMTP:\n+        \"\"\"Create an SMTP connection, selecting the correct protocol for the port.\n+\n+        Port 465 uses implicit TLS (``SMTP_SSL``).  All other ports use\n+        ``SMTP`` + ``STARTTLS``.\n+\n+        When the host resolves to an IPv6 address that is unreachable\n+        (common on networks without IPv6 routing), the default connection can\n+        hang until the socket timeout expires.  We retry connection-level\n+        failures through an IPv4-only socket path, without mutating global\n+        resolver state.  TLS verification errors are not retried.\n+\n+        Returns a connected SMTP object with TLS established — callers\n+        can proceed directly to ``login()``.\n+        \"\"\"\n+        ctx = ssl.create_default_context()\n+        host = self._smtp_host\n+        port = self._smtp_port\n+\n+        def _connect(*, ipv4_only: bool = False) -> smtplib.SMTP:\n+            \"\"\"Attempt one SMTP connection.\"\"\"\n+            smtp_cls = _IPv4SMTP if ipv4_only else smtplib.SMTP\n+            smtp_ssl_cls = _IPv4SMTP_SSL if ipv4_only else smtplib.SMTP_SSL\n+            if port == 465:\n+                return smtp_ssl_cls(host, port, timeout=SMTP_CONNECT_TIMEOUT, context=ctx)\n+            smtp = smtp_cls(host, port, timeout=SMTP_CONNECT_TIMEOUT)\n+            try:\n+                smtp.starttls(context=ctx)\n+            except Exception:\n+                smtp.close()\n+                raise\n+            return smtp\n+\n+        try:\n+            return _connect()\n+        except (socket.timeout, TimeoutError, ConnectionError, OSError) as exc:\n+            if isinstance(exc, ssl.SSLError):\n+                raise\n+            # Connection-level failure (may be unreachable IPv6).\n+            # Retry with IPv4 only.\n+            return _connect(ipv4_only=True)\n+\n     async def connect(self) -> bool:\n         \"\"\"Connect to the IMAP server and start polling for new messages.\"\"\"\n         try:","@@ -316,10 +416,11 @@ async def connect(self) -> bool:\n \n         try:\n             # Test SMTP connection\n-            smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)\n-            smtp.starttls(context=ssl.create_default_context())\n-            smtp.login(self._address, self._password)\n-            smtp.quit()\n+            smtp = self._connect_smtp()\n+            try:\n+                smtp.login(self._address, self._password)\n+            finally:\n+                smtp.quit()\n             logger.info(\"[Email] SMTP connection test passed.\")\n         except Exception as e:\n             logger.error(\"[Email] SMTP connection failed: %s\", e)","@@ -555,9 +656,8 @@ def _send_email(\n \n         msg.attach(MIMEText(body, \"plain\", \"utf-8\"))\n \n-        smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)\n+        smtp = self._connect_smtp()\n         try:\n-            smtp.starttls(context=ssl.create_default_context())\n             smtp.login(self._address, self._password)\n             smtp.send_message(msg)\n         finally:","@@ -677,9 +777,8 @@ def _send_email_with_attachments(\n             except Exception as e:\n                 logger.warning(\"[Email] Failed to attach %s: %s\", file_path, e)\n \n-        smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)\n+        smtp = self._connect_smtp()\n         try:\n-            smtp.starttls(context=ssl.create_default_context())\n             smtp.login(self._address, self._password)\n             smtp.send_message(msg)\n         finally:","@@ -756,9 +855,8 @@ def _send_email_with_attachment(\n             part.add_header(\"Content-Disposition\", f\"attachment; filename={fname}\")\n             msg.attach(part)\n \n-        smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)\n+        smtp = self._connect_smtp()\n         try:\n-            smtp.starttls(context=ssl.create_default_context())\n             smtp.login(self._address, self._password)\n             smtp.send_message(msg)\n         finally:"]},{"file":"tests/gateway/test_email.py","hunks":["@@ -18,7 +18,7 @@\n from email.mime.multipart import MIMEMultipart\n from email.mime.base import MIMEBase\n from email import encoders\n-from unittest.mock import patch, MagicMock, AsyncMock\n+from unittest.mock import patch, MagicMock, AsyncMock, ANY\n \n from gateway.platforms.base import SendResult\n ","@@ -1265,5 +1265,123 @@ def test_send_imap_id_swallows_errors_for_non_supporting_servers(self):\n         mock_imap.xatom.assert_called_once()\n \n \n+class TestConnectSmtp(unittest.TestCase):\n+    \"\"\"Test _connect_smtp() helper: protocol selection and IPv6 fallback.\"\"\"\n+\n+    def _make_adapter(self, port=\"587\"):\n+        from gateway.config import PlatformConfig\n+        with patch.dict(os.environ, {\n+            \"EMAIL_ADDRESS\": \"[redacted:email]\",\n+            \"EMAIL_PASSWORD\": \"secret\",\n+            \"EMAIL_IMAP_HOST\": \"imap.test.com\",\n+            \"EMAIL_SMTP_HOST\": \"smtp.test.com\",\n+            \"EMAIL_SMTP_PORT\": port,\n+        }):\n+            from gateway.platforms.email import EmailAdapter\n+            return EmailAdapter(PlatformConfig(enabled=True))\n+\n+    def test_port_587_uses_smtp_with_starttls(self):\n+        \"\"\"Port 587 should use smtplib.SMTP + STARTTLS.\"\"\"\n+        adapter = self._make_adapter(\"587\")\n+\n+        with patch(\"smtplib.SMTP\") as mock_smtp, \\\n+             patch(\"smtplib.SMTP_SSL\") as mock_smtp_ssl:\n+            mock_server = MagicMock()\n+            mock_smtp.return_value = mock_server\n+\n+            result = adapter._connect_smtp()\n+\n+            mock_smtp.assert_called_once()\n+            mock_smtp_ssl.assert_not_called()\n+            mock_server.starttls.assert_called_once()\n+            self.assertIs(result, mock_server)\n+\n+    def test_port_465_uses_smtp_ssl(self):\n+        \"\"\"Port 465 should use smtplib.SMTP_SSL (implicit TLS).\"\"\"\n+        adapter = self._make_adapter(\"465\")\n+\n+        with patch(\"smtplib.SMTP\") as mock_smtp, \\\n+             patch(\"smtplib.SMTP_SSL\") as mock_smtp_ssl:\n+            mock_server = MagicMock()\n+            mock_smtp_ssl.return_value = mock_server\n+\n+            result = adapter._connect_smtp()\n+\n+            mock_smtp_ssl.assert_called_once()\n+            mock_smtp.assert_not_called()\n+            self.assertIs(result, mock_server)\n+\n+    def test_ipv6_timeout_falls_back_to_ipv4(self):\n+        \"\"\"When default connection times out, retry with an IPv4-only SMTP path.\"\"\"\n+        import socket as _socket\n+        from gateway.platforms import email as email_mod\n+\n+        adapter = self._make_adapter(\"587\")\n+\n+        with patch(\"smtplib.SMTP\", side_effect=_socket.timeout(\"timed out\")), \\\n+             patch.object(email_mod, \"_IPv4SMTP\") as mock_ipv4_smtp:\n+            mock_server = MagicMock()\n+            mock_ipv4_smtp.return_value = mock_server\n+\n+            result = adapter._connect_smtp()\n+\n+            self.assertIs(result, mock_server)\n+            mock_ipv4_smtp.assert_called_once_with(\"smtp.test.com\", 587, timeout=30)\n+            mock_server.starttls.assert_called_once()\n+\n+    def test_port_465_ipv6_fallback(self):\n+        \"\"\"Port 465 IPv6 timeout falls back to IPv4 with SMTP_SSL.\"\"\"\n+        import socket as _socket\n+        from gateway.platforms import email as email_mod\n+\n+        adapter = self._make_adapter(\"465\")\n+\n+        with patch(\"smtplib.SMTP_SSL\", side_effect=_socket.timeout(\"timed out\")), \\\n+             patch.object(email_mod, \"_IPv4SMTP_SSL\") as mock_ipv4_smtp_ssl:\n+            mock_server = MagicMock()\n+            mock_ipv4_smtp_ssl.return_value = mock_server\n+\n+            result = adapter._connect_smtp()\n+\n+            self.assertIs(result, mock_server)\n+            mock_ipv4_smtp_ssl.assert_called_once_with(\n+                \"smtp.test.com\", 465, timeout=30, context=ANY,\n+            )\n+\n+    def test_tls_verification_error_does_not_retry_ipv4(self):\n+        \"\"\"Certificate failures are security errors, not IPv6 reachability failures.\"\"\"\n+        import ssl as _ssl\n+        from gateway.platforms import email as email_mod\n+\n+        adapter = self._make_adapter(\"465\")\n+\n+        with patch(\"smtplib.SMTP_SSL\", side_effect=_ssl.SSLError(\"cert verify failed\")), \\\n+             patch.object(email_mod, \"_IPv4SMTP_SSL\") as mock_ipv4_smtp_ssl:\n+            with self.assertRaises(_ssl.SSLError):\n+                adapter._connect_smtp()\n+\n+            mock_ipv4_smtp_ssl.assert_not_called()\n+\n+    def test_ipv4_connection_does_not_mutate_global_resolver(self):\n+        \"\"\"IPv4 fallback must not monkeypatch process-global socket state.\"\"\"\n+        import socket as _socket\n+        from gateway.platforms.email import _create_ipv4_connection\n+\n+        original_getaddrinfo = _socket.getaddrinfo\n+        fake_sock = MagicMock()\n+\n+        with patch(\n+            \"socket.getaddrinfo\",\n+            return_value=[(_socket.AF_INET, _socket.SOCK_STREAM, 6, \"\", (\"192.0.2.1\", 587))],\n+        ) as mock_getaddrinfo, patch(\"socket.socket\", return_value=fake_sock):\n+            result = _create_ipv4_connection(\"smtp.test.com\", 587, 30)\n+\n+        self.assertIs(result, fake_sock)\n+        mock_getaddrinfo.assert_called_once_with(\n+            \"smtp.test.com\", 587, _socket.AF_INET, _socket.SOCK_STREAM,\n+        )\n+        self.assertIs(_socket.getaddrinfo, original_getaddrinfo)\n+\n+\n if __name__ == \"__main__\":\n     unittest.main()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}