fix(email): SMTP SSL + IPv4 + HTML email support (#11941) - #46626
fix(email): SMTP SSL + IPv4 + HTML email support (#11941)#46626swissly wants to merge 4 commits into
Conversation
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 NousResearch#46018
…text cache - 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)
- 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
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR enhances the email platform adapter by adding optional Markdown→styled-HTML email rendering and introducing a custom SMTP connection path that prefers IPv4 and reuses an SSL context.
Changes:
- Added HTML email generation (Markdown conversion + inline styling) with a plain-text fallback.
- Implemented a custom SMTP socket/connect path (IPv4-first) and reused an SSLContext across sends.
- Updated SMTP connection testing and send paths to use the new connector.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # 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, | ||
| ) |
| _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;"'), | ||
| ] |
| html = re.sub( | ||
| rf'<{tag}(\s|>)', | ||
| rf'<{tag} {style}\1', | ||
| html, | ||
| ) |
| import markdown as _md | ||
|
|
| if self._html_format: | ||
| try: | ||
| html_body = _md.markdown( | ||
| body, | ||
| extensions=["tables", "fenced_code", "nl2br"], | ||
| ) |
| # 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) |
|
Duplicate of #27680 — the SMTP_SSL-for-port-465 + force-IPv4 reliability fix in this PR is the same change as the canonical open #27680 ( The HTML email support (Markdown→HTML, Related: #46019 (your prior SMTP_SSL+IPv4 attempt). |
- 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 <code> inside <pre> (transparent override) - Lazy-import markdown only when html_format is enabled
Addressed Copilot Review Round 2Fixes Applied
Verification
|
|
Closing — the SMTP_SSL/IPv4 fix is a duplicate of #27680. Will re-submit the HTML email support as a focused standalone PR. |
Summary
Fixes email adapter reliability issues and adds HTML email support with proper configuration.
Changes
1. SMTP SSL & IPv4 Fix (closes #11941)
SMTP_SSLfor port 465 connections (instead ofSMTP+starttls)_connect_smtp_socket()method2. HTML Email Support (optional)
multipart/alternativeemails (plain text + HTML)platforms.email.html_format: true(default: enabled)3. Security Note
Added code comment explaining why we don't sanitize HTML:
html_format: falseConfiguration
Testing
Related