From 0c21161ab411e7311231669fa34c3ea8e81d161d Mon Sep 17 00:00:00 2001 From: Swissly Date: Sun, 14 Jun 2026 07:13:22 +0000 Subject: [PATCH 1/4] fix: use SMTP_SSL for port 465 and force IPv4 in email adapter The email adapter used smtplib.SMTP() + starttls() for all SMTP connections, which fails on port 465 (implicit TLS). Port 465 requires SMTP_SSL from the start. Additionally, when IPv6 is unreachable but AAAA records exist, socket.create_connection hangs. This adds a _connect_smtp() helper that selects SMTP_SSL for port 465 and forces IPv4 resolution. Fixes #46018 --- gateway/platforms/email.py | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index 4eb4972b24ec5..a92b99857afff 100644 --- a/gateway/platforms/email.py +++ b/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 @@ -293,6 +294,30 @@ 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, using SMTP_SSL for port 465 (implicit TLS). + + Forces IPv4 to avoid hanging on unreachable IPv6 addresses. + """ + ctx = ssl.create_default_context() + # Force IPv4 — many SMTP servers have AAAA records but IPv6 is + # unreachable on this host, causing socket.create_connection to hang. + orig_gai = socket.getaddrinfo + + def _ipv4_gai(host, port, family=0, type=0, proto=0, flags=0): + return orig_gai(host, port, socket.AF_INET, type, proto, flags) + + socket.getaddrinfo = _ipv4_gai + try: + if self._smtp_port == 465: + smtp = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port, timeout=30, context=ctx) + else: + smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) + smtp.starttls(context=ctx) + finally: + socket.getaddrinfo = orig_gai + return smtp + async def connect(self) -> bool: """Connect to the IMAP server and start polling for new messages.""" try: @@ -316,8 +341,7 @@ 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 = self._connect_smtp() smtp.login(self._address, self._password) smtp.quit() logger.info("[Email] SMTP connection test passed.") @@ -555,9 +579,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 +700,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 +778,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: From f7367d9f99aace841979a089114ab576162f17dd Mon Sep 17 00:00:00 2001 From: Swissly Date: Sun, 14 Jun 2026 07:23:16 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20thread-safe=20IPv4,=20socket=20leak,=20SSL=20contex?= =?UTF-8?q?t=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace socket.getaddrinfo monkey-patch with manual socket creation that prefers IPv4 but falls back to default resolution (IPv6-safe) - Pre-connect socket and pass to SMTP/SMTP_SSL via sock assignment, with proper banner reading (getreply) and ehlo - Add try/finally in health check to prevent socket leaks on login fail - Cache SSLContext on self._smtp_ssl_ctx instead of creating per-call - Wrap SSL socket manually for port 465 (bypassing _get_socket) --- gateway/platforms/email.py | 79 +++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index a92b99857afff..1e3b14f5cb669 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -269,6 +269,9 @@ def __init__(self, config: PlatformConfig): self._seen_uids_max: int = 2000 # cap to prevent unbounded memory growth self._poll_task: Optional[asyncio.Task] = None + # Reusable SSL context for SMTP connections + self._smtp_ssl_ctx: ssl.SSLContext = ssl.create_default_context() + # Map chat_id (sender email) -> last subject + message-id for threading self._thread_context: Dict[str, Dict[str, str]] = {} @@ -297,26 +300,59 @@ def _trim_seen_uids(self) -> None: def _connect_smtp(self) -> smtplib.SMTP: """Create an SMTP connection, using SMTP_SSL for port 465 (implicit TLS). - Forces IPv4 to avoid hanging on unreachable IPv6 addresses. + Tries IPv4 first to avoid hanging on unreachable AAAA records, + then falls back to default resolution (which includes IPv6) if + no A records are found. This is thread-safe — no global state + is mutated. """ - ctx = ssl.create_default_context() - # Force IPv4 — many SMTP servers have AAAA records but IPv6 is - # unreachable on this host, causing socket.create_connection to hang. - orig_gai = socket.getaddrinfo + sock = self._connect_smtp_socket() + if self._smtp_port == 465: + # SMTP_SSL wraps the socket in _get_socket() during connect(), + # but we bypass connect() by providing a pre-connected socket, + # so we wrap and initialize manually. + ssl_sock = self._smtp_ssl_ctx.wrap_socket( + sock, server_hostname=self._smtp_host, + ) + smtp: smtplib.SMTP = smtplib.SMTP_SSL(context=self._smtp_ssl_ctx) + smtp.sock = ssl_sock + smtp.file = ssl_sock.makefile("rb") + smtp.getreply() # read server greeting banner + else: + smtp = smtplib.SMTP() + smtp.sock = sock + smtp.file = sock.makefile("rb") + smtp.getreply() # read server greeting banner + smtp.ehlo() + if self._smtp_port != 465: + smtp.starttls(context=self._smtp_ssl_ctx) + smtp.ehlo() + return smtp - def _ipv4_gai(host, port, family=0, type=0, proto=0, flags=0): - return orig_gai(host, port, socket.AF_INET, type, proto, flags) + def _connect_smtp_socket(self, timeout: float = 30) -> socket.socket: + """Resolve SMTP host preferring IPv4, then connect. - socket.getaddrinfo = _ipv4_gai - try: - if self._smtp_port == 465: - smtp = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port, timeout=30, context=ctx) - else: - smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) - smtp.starttls(context=ctx) - finally: - socket.getaddrinfo = orig_gai - return smtp + Tries IPv4 (A records) first so hosts with broken IPv6 don't + hang. Falls back to default resolution if no A records exist. + Thread-safe — no global state is mutated. + """ + # Try IPv4 first + addrs = socket.getaddrinfo( + self._smtp_host, self._smtp_port, socket.AF_INET, socket.SOCK_STREAM, + ) + if not addrs: + # No A records — fall back to default (may include IPv6) + addrs = socket.getaddrinfo( + self._smtp_host, self._smtp_port, type=socket.SOCK_STREAM, + ) + for family, type_, proto, _, addr in addrs: + sock = socket.socket(family, type_, proto) + sock.settimeout(timeout) + try: + sock.connect(addr) + return sock + except OSError: + sock.close() + raise OSError(f"Cannot connect to {self._smtp_host}:{self._smtp_port}") async def connect(self) -> bool: """Connect to the IMAP server and start polling for new messages.""" @@ -342,8 +378,13 @@ async def connect(self) -> bool: try: # Test SMTP connection smtp = self._connect_smtp() - smtp.login(self._address, self._password) - smtp.quit() + try: + smtp.login(self._address, self._password) + finally: + try: + smtp.quit() + except Exception: + smtp.close() logger.info("[Email] SMTP connection test passed.") except Exception as e: logger.error("[Email] SMTP connection failed: %s", e) From ad66290f4db5a79d815dce9bb69acf486f19302a Mon Sep 17 00:00:00 2001 From: Swissly Date: Mon, 15 Jun 2026 12:03:58 +0000 Subject: [PATCH 3/4] Add html_format config toggle with security comment - Add platforms.email.html_format config option (default: true) - Gate HTML conversion behind config check - Document security trade-off: no bleach needed for self-to-self emails - Config toggle provides kill-switch for paranoid deployments --- gateway/platforms/email.py | 99 +++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index 1e3b14f5cb669..45658e81f2c88 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -34,6 +34,8 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +import markdown as _md + from gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, @@ -45,6 +47,71 @@ from gateway.config import Platform, PlatformConfig logger = logging.getLogger(__name__) + +# ── HTML Email Styling ────────────────────────────────────────────────────── +# Inline CSS for maximum email client compatibility (Gmail, Outlook, Apple Mail). + +_HERMES_EMAIL_CSS = """\ + + + + +
+ +""" + +_HERMES_EMAIL_FOOTER = """\ + +
+ Sent by Hermes Agent · + + hermes-agent.nousresearch.com + +
+
+ + +""" + +# Pre-process CSS into markdown-compatible HTML wrapper +# We inject styles per-element after markdown conversion for email client compat. +_HERMES_EMAIL_ELEMENT_STYLES = [ + ("h1", 'style="font-size:24px;font-weight:700;color:#1a202c;margin:24px 0 12px;border-bottom:2px solid #667eea;padding-bottom:8px;"'), + ("h2", 'style="font-size:20px;font-weight:700;color:#2d3748;margin:24px 0 10px;border-bottom:1px solid #e2e8f0;padding-bottom:6px;"'), + ("h3", 'style="font-size:17px;font-weight:600;color:#4a5568;margin:18px 0 8px;"'), + ("h4", 'style="font-size:15px;font-weight:600;color:#718096;margin:14px 0 6px;"'), + ("p", 'style="margin:0 0 12px;"'), + ("ul", 'style="margin:0 0 12px;padding-left:24px;"'), + ("ol", 'style="margin:0 0 12px;padding-left:24px;"'), + ("li", 'style="margin-bottom:4px;"'), + ("blockquote", 'style="margin:12px 0;padding:12px 16px;border-left:4px solid #667eea;background:#f7fafc;color:#4a5568;font-style:italic;"'), + ("table", 'style="border-collapse:collapse;width:100%;margin:12px 0;font-size:14px;"'), + ("th", 'style="background:#667eea;color:#fff;padding:8px 12px;text-align:left;font-weight:600;"'), + ("td", 'style="padding:8px 12px;border-bottom:1px solid #e2e8f0;"'), + ("tr:nth-child(even)", 'style="background:#f7fafc;"'), + ("code", 'style="background:#edf2f7;padding:2px 5px;border-radius:3px;font-size:13px;font-family:Menlo,Monaco,Consolas,monospace;"'), + ("pre", 'style="background:#2d3748;color:#e2e8f0;padding:16px;border-radius:6px;overflow-x:auto;font-size:13px;line-height:1.5;"'), + ("pre code", 'style="background:transparent;padding:0;color:inherit;"'), + ("a", 'style="color:#667eea;text-decoration:none;"'), + ("strong", 'style="color:#1a202c;"'), + ("em", 'style="color:#4a5568;"'), + ("hr", 'style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;"'), +] + +def _style_html_email(html: str) -> str: + """Inject inline CSS styles into HTML elements for email client compat.""" + import re + for tag, style in _HERMES_EMAIL_ELEMENT_STYLES: + # Handle tags with and without existing attributes + html = re.sub( + rf'<{tag}(\s|>)', + rf'<{tag} {style}\1', + html, + ) + return html # Automated sender patterns — emails from these are silently ignored _NOREPLY_PATTERNS = ( "noreply", "no-reply", "no_reply", "donotreply", "do-not-reply", @@ -264,6 +331,20 @@ def __init__(self, config: PlatformConfig): extra = config.extra or {} self._skip_attachments = extra.get("skip_attachments", False) + # HTML email format — converts Markdown bodies to styled HTML. + # platforms: + # email: + # html_format: true # default: true (enabled) + # + # SECURITY NOTE: The Markdown library passes through raw HTML by default. + # We intentionally do NOT sanitize with bleach/allowlists because: + # 1. Hermes generates its own email bodies (LLM output, not user input) + # 2. Emails are sent to the configured address (self-to-self) + # 3. The config toggle provides a kill-switch: set html_format: false + # If the threat model changes (e.g. forwarding untrusted content), + # add bleach.clean() here with an allowed-tags list. + self._html_format = extra.get("html_format", True) + # Track message IDs we've already processed to avoid duplicates self._seen_uids: set = set() self._seen_uids_max: int = 2000 # cap to prevent unbounded memory growth @@ -596,8 +677,8 @@ def _send_email( body: str, reply_to_msg_id: Optional[str] = None, ) -> str: - """Send an email via SMTP. Runs in executor thread.""" - msg = MIMEMultipart() + """Send an email via SMTP with HTML formatting. Runs in executor thread.""" + msg = MIMEMultipart("alternative") msg["From"] = self._address msg["To"] = to_addr @@ -618,8 +699,22 @@ def _send_email( msg_id = f"" msg["Message-ID"] = msg_id + # Plain text fallback msg.attach(MIMEText(body, "plain", "utf-8")) + # HTML version with inline CSS (if enabled) + if self._html_format: + try: + html_body = _md.markdown( + body, + extensions=["tables", "fenced_code", "nl2br"], + ) + html_body = _style_html_email(html_body) + html_email = _HERMES_EMAIL_CSS + html_body + _HERMES_EMAIL_FOOTER + msg.attach(MIMEText(html_email, "html", "utf-8")) + except Exception as e: + logger.warning("[Email] HTML conversion failed, sending plain only: %s", e) + smtp = self._connect_smtp() try: smtp.login(self._address, self._password) From fc8e1608743a5f2b056bf3c08f4f255f454c2b1c Mon Sep 17 00:00:00 2001 From: Swissly Date: Mon, 15 Jun 2026 12:24:28 +0000 Subject: [PATCH 4/4] fix: address Copilot review round 2 - Wrap socket.getaddrinfo AF_INET in try/except gaierror (IPv6-only hosts) - Remove CSS selectors (tr:nth-child, pre code) from style list - Use negative lookahead to skip tags with existing style= attribute - Special handling for inside
 (transparent override)
- Lazy-import markdown only when html_format is enabled
---
 gateway/platforms/email.py | 32 +++++++++++++++++++++-----------
 1 file changed, 21 insertions(+), 11 deletions(-)

diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py
index 45658e81f2c88..f59f86fffd54c 100644
--- a/gateway/platforms/email.py
+++ b/gateway/platforms/email.py
@@ -34,8 +34,6 @@
 from pathlib import Path
 from typing import Any, Dict, List, Optional, Tuple
 
-import markdown as _md
-
 from gateway.platforms.base import (
     BasePlatformAdapter,
     MessageEvent,
@@ -91,28 +89,36 @@
     ("table", 'style="border-collapse:collapse;width:100%;margin:12px 0;font-size:14px;"'),
     ("th", 'style="background:#667eea;color:#fff;padding:8px 12px;text-align:left;font-weight:600;"'),
     ("td", 'style="padding:8px 12px;border-bottom:1px solid #e2e8f0;"'),
-    ("tr:nth-child(even)", 'style="background:#f7fafc;"'),
+    ("tr", 'style="border-bottom:1px solid #e2e8f0;"'),
     ("code", 'style="background:#edf2f7;padding:2px 5px;border-radius:3px;font-size:13px;font-family:Menlo,Monaco,Consolas,monospace;"'),
     ("pre", 'style="background:#2d3748;color:#e2e8f0;padding:16px;border-radius:6px;overflow-x:auto;font-size:13px;line-height:1.5;"'),
-    ("pre code", 'style="background:transparent;padding:0;color:inherit;"'),
     ("a", 'style="color:#667eea;text-decoration:none;"'),
     ("strong", 'style="color:#1a202c;"'),
     ("em", 'style="color:#4a5568;"'),
     ("hr", 'style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;"'),
 ]
 
+
 def _style_html_email(html: str) -> str:
     """Inject inline CSS styles into HTML elements for email client compat."""
     import re
     for tag, style in _HERMES_EMAIL_ELEMENT_STYLES:
-        # Handle tags with and without existing attributes
+        # Skip tags that already have a style attribute to avoid duplication
+        # Use negative lookahead: only match )',
+            rf'<{tag}(?!\s+style=)(\s|>)',
             rf'<{tag} {style}\1',
             html,
         )
+    # Special handling:  inside 
 should be transparent
+    # Match 
... and apply override style
+    html = re.sub(
+        r'(]*>.*?))',
+        r'\1 socket.socket:
         hang. Falls back to default resolution if no A records exist.
         Thread-safe — no global state is mutated.
         """
-        # Try IPv4 first
-        addrs = socket.getaddrinfo(
-            self._smtp_host, self._smtp_port, socket.AF_INET, socket.SOCK_STREAM,
-        )
+        # Try IPv4 first — gaierror means no A records (IPv6-only host)
+        try:
+            addrs = socket.getaddrinfo(
+                self._smtp_host, self._smtp_port, socket.AF_INET, socket.SOCK_STREAM,
+            )
+        except socket.gaierror:
+            addrs = []
         if not addrs:
             # No A records — fall back to default (may include IPv6)
             addrs = socket.getaddrinfo(
@@ -705,6 +714,7 @@ def _send_email(
         # HTML version with inline CSS (if enabled)
         if self._html_format:
             try:
+                import markdown as _md
                 html_body = _md.markdown(
                     body,
                     extensions=["tables", "fenced_code", "nl2br"],