feat(email): Markdown-to-HTML rendering for all 4 send paths - #73294
feat(email): Markdown-to-HTML rendering for all 4 send paths#73294swissly wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds Markdown-to-HTML rendering for outbound emails in the Email platform plugin, aiming to send multipart/alternative messages (plain + HTML) across all outbound send paths, with a platforms.email.html_format opt-out.
Changes:
- Introduces a Markdown → styled-HTML renderer using inline CSS suitable for email clients.
- Adds shared helpers to attach plain + optional HTML body parts, and wires them into reply + attachment send paths.
- Extends the standalone SMTP sender path to optionally send HTML alongside plain text.
Comments suppressed due to low confidence (1)
plugins/platforms/email/adapter.py:1023
- With
html_format: false,_send_email()still constructs amultipart/alternativemessage (and attachment paths still nest analternativepart). This diverges from the PR/issue expectation that opting out preserves the prior plain-text-only MIME shape. Consider using a plainMIMETextmessage when HTML is disabled, and only creatingmultipart/alternativewhenhtml_formatis enabled.
"""Send an email via SMTP. Runs in executor thread."""
msg = MIMEMultipart("alternative")
msg["From"] = self._address
msg["To"] = to_addr
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Regex to match <code> inside <pre> blocks — override inline code styling | ||
| _PRE_CODE_OVERRIDE = ( | ||
| re.compile(r"(<pre[^>]*>.*?)<code(\s|>)", re.DOTALL), | ||
| r'\1<code style="background:transparent;padding:0;color:inherit;font-size:13px;font-family:Menlo,Monaco,Consolas,monospace;"\2', | ||
| ) | ||
|
|
||
|
|
||
| def _markdown_to_html_email(body: str) -> str: | ||
| """Convert Markdown body to styled HTML email content.""" | ||
| import markdown as _md_mod | ||
| html = _md_mod.markdown(body, extensions=["tables", "fenced_code", "nl2br"]) | ||
| # Inject inline styles per element (Gmail strips <style> blocks) | ||
| for tag, style in _HERMES_EMAIL_STYLES: | ||
| html = re.sub(rf"<{tag}(\s|>)", rf"<{tag} {style}\1", html) | ||
| # Override <code> inside <pre> blocks (two-pass: inline code already styled above) | ||
| html = _PRE_CODE_OVERRIDE[0].sub(_PRE_CODE_OVERRIDE[1], html) | ||
| # Use .replace() instead of .format() — body may contain { } braces | ||
| return _HERMES_EMAIL_HTML_TEMPLATE.replace("{body}", html) |
| container.attach(MIMEText(body, "plain", "utf-8")) | ||
| if self._html_format: | ||
| try: | ||
| html = _markdown_to_html_email(body) | ||
| container.attach(MIMEText(html, "html", "utf-8")) | ||
| except Exception as e: | ||
| logger.warning("[Email] HTML conversion failed, sending plain only: %s", e, exc_info=True) |
| # HTML email formatting — config: platforms.email.html_format (default: true) | ||
| self._html_format = extra.get("html_format", True) | ||
|
|
| def _create_body_part(self, body: str) -> MIMEMultipart: | ||
| """Create a multipart/alternative body part (for use inside multipart/mixed).""" | ||
| alt = MIMEMultipart("alternative") | ||
| self._attach_parts(alt, body) | ||
| return alt |
Related: #54107 is the earlier open live-path HTML-email implementation. Its current diff preserves detected existing HTML and has regression coverage, whereas this patch always Markdown-renders the body. These are competing rendering contracts, so a maintainer should choose/consolidate rather than treat them as duplicates. |
Copilot Review — All 4 findings addressed ✅1. Duplicate 2. ImportError handling ✅ 3. Unit tests ✅ (10 tests, all passing)
4. |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for porting the Markdown-to-HTML work to the live email adapter. The feature premise is valid: current main sends only MIMEText(..., "plain") in the primary path (plugins/platforms/email/adapter.py:949), both attachment paths (:1063, :1143), and standalone delivery (:1219).
Problems
plugins/platforms/email/adapter.py:109-126converts and styles the entire body without a raw-HTML safety step. The existing Matrix rich-output path pre-sanitizes before conversion (plugins/platforms/matrix/adapter.py:4561,:837-854) and allowlist-sanitizes output (:816-823). The original issue discussion also raised disabling raw HTML/JS. Please define and test the email policy before shipping this renderer.- The member discussion correctly identifies a competing contract with #54107: it preserves detected existing HTML, while this patch always Markdown-renders. This needs consolidation rather than an implicit behavior change.
website/docs/user-guide/messaging/email.md:123still says replies are plain text, but this PR adds a publichtml_formatoption without documentation.
Suggested changes
- Add an explicit sanitized/raw-HTML policy with security regression tests, reconcile it with #54107, and document
html_formatplus MIME behavior. - Replace the tautological brace assertion in
tests/gateway/test_email_html.py:48with an exact rendering assertion.
Automated hermes-sweeper review.
| def _markdown_to_html_email(body: str) -> str: | ||
| """Convert Markdown body to styled HTML email content.""" | ||
| import markdown as _md_mod | ||
| html = _md_mod.markdown(body, extensions=["tables", "fenced_code", "nl2br"]) |
There was a problem hiding this comment.
This renderer converts and styles the full body without a raw-HTML safety step. The established Matrix path pre-sanitizes Markdown and allowlist-sanitizes rendered HTML (plugins/platforms/matrix/adapter.py:837-854, :816-823). Please define and test the email policy for raw HTML, event attributes, and unsafe URLs before sending it as text/html.
| """Body with { } braces must not break template substitution.""" | ||
| from plugins.platforms.email.adapter import _markdown_to_html_email | ||
| html = _markdown_to_html_email("Use `{code}` here") | ||
| assert "{code}" not in html or "code" in html # braces consumed or preserved |
There was a problem hiding this comment.
This assertion is tautological: the generated template/styles already contain the substring code, so the right-hand side makes it pass regardless of brace handling. Assert the exact expected rendered code span or verify conversion completes while preserving the intended content.
Tecnium Review — All findings addressed ✅1. HTML sanitization policy ✅
2. HTML detection for pre-existing HTML ✅ 3. Documentation updated ✅
4. Test fixes ✅
|
72c960d to
9529ee3
Compare
|
Test comment via gh CLI |
c551867 to
2c5008a
Compare
|
Rebased onto current main (2026-08-03, +149 commits upstream). Branch was rebuilt clean from origin/main — only the 3 email commits (HTML rendering, 465 SMTP_SSL, tests) are included; prior local patches (tool-call repair, OpenViking) are no longer in this branch. 54/54 email tests green. PR #34603 (overlap) was closed by its author — this PR remains the consolidated HTML-email fix. Note: upstream merged secret-scope fixes (f08f403, 359ff01) which our standalone-send path now composes with cleanly. |
Nested escaped text like &lt;APIKEY&gt; was decoded twice in one _strip_html call (& -> &, then < -> <), producing <APIKEY> instead of literal <APIKEY>. Decode </> before & so each entity layer is decoded at most once (issue NousResearch#68704). Same fix as PR NousResearch#68707 (tooiuiiu); applied locally for immediate effect, kept out of PR NousResearch#73294 (send-path scope). Tests: +2 regression cases.
|
Triage update for the |
Resolves the unresolved merge conflict left by hermes update (main had UU state on adapter.py): HEAD kept secret-scope reads, incoming kept the HTML block. Both merged; sanitizer + placeholder strategy ported from 72c960d91a (both Copilot and Tecnium reviews passed). - _markdown_to_html_email: placeholder strategy for <pre><code> blocks (no duplicate style=), allowlist sanitizer (_EmailHtmlSanitizer), _is_html detection for already-HTML bodies - _attach_parts: plain + HTML in multipart/alternative, ImportError separated from general Exception - _create_body_part: returns MIMEText when html_format disabled - _standalone_send: honors html_format (was hardcoded plain), uses _get_secret scope-aware reads (conflict merge, not os.getenv) - tests: 20 new tests (markdown, MIME structure, sanitization, detection) Supersedes prior branch base (3714 files stale); fresh branch off main.
_standalone_send (cron delivery path) used SMTP()+STARTTLS for ALL ports; port 465 requires implicit TLS. Matches _connect_smtp behavior (class path). Found by live E2E test against smtp.breitband.ch:465 (timeout without fix).
Regression tests for the port-selection fix found by live E2E test (smtp.breitband.ch:465 hung with plain SMTP+STARTTLS).
Ported from PR NousResearch#36853 (chtse53): cron wrappers ('Cronjob Response: <name>') and model commentary before the first HTML tag, plus trailing prose after </html> or the last block-level tag, are stripped before sanitizing an already-HTML body. Prevents broken plain-text wrappers from rendering inside HTML emails. NousResearch#36853 targets gateway/platforms/email.py which no longer exists on main (adapters migrated to bundled plugins in 5600105); this ports the feature to plugins/platforms/email/adapter.py. Contributor credit kept. 5 regression tests.
919258f to
e1a695b
Compare
What does this PR do?
Adds automatic Markdown-to-HTML rendering for all outbound emails. Sends emails as
multipart/alternativewith bothtext/plain(raw body) andtext/html(styled HTML with inline CSS), so HTML-capable clients render rich formatting while plain-text clients fall back gracefully.Addresses: Closes #11941
How it works
_markdown_to_html_email(body)converts Markdown to HTML using themarkdownlibrary (tables, fenced code, nl2br), then injects inline CSS per element (because Gmail strips<style>blocks).All 4 send paths are updated:
_send_email→MIMEMultipart("alternative")+_attach_body(msg, body)_send_email_with_attachment→_create_body_part(body)insidemultipart/mixed_send_email_with_attachments→_create_body_part(body)insidemultipart/mixed_standalone_send→MIMEMultipart("alternative")withhtml_formatconfig checkConfig opt-out:
platforms.email.html_format: falsedisables HTML (default:true).Graceful fallback: if
markdownis not installed or conversion fails, plain text is sent as before.Security: allowlist sanitizer (
_EmailHtmlSanitizer) strips event handlers,javascript:/data:URLs, and<script>/<style>blocks on all outbound HTML.Already-HTML bodies:
_is_html()detects them,_trim_html_preamble_postamble()strips cron-wrapper/model commentary before the first HTML tag and trailing prose after</html>or the last block-level tag (ported from fix(email): HTML emails sent as plain text — two critical bugs #36853, commit592c9b2f), then the sanitizer applies.Standalone SMTP fix:
_standalone_sendusesSMTP_SSLon port 465 (implicit TLS) instead ofSMTP+STARTTLS, matching_connect_smtp(found by live E2E against smtp.breitband.ch:465).Supersedes / Overlapping PRs
gateway/platforms/email.pygateway/platforms/email.py(bundled-plugin migration 5600105)&last), independently mergeableRebase note (2026-08-03)
Branch was 3714 files behind main. Rebuilt fresh from
main(commit9529ee30), carrying the full review history (Copilot + Tecnium rounds on the prior base). Rebased again onto current main (2026-08-03, +149 commits) — only the 4 email commits are in this branch. Test suite:tests/gateway/test_email_html.py(25 tests) +tests/gateway/test_email.pyall pass (61 total).