feat(email): Markdown-to-HTML rendering for outbound emails - #46619
feat(email): Markdown-to-HTML rendering for outbound emails#46619swissly wants to merge 2 commits into
Conversation
Converts Markdown content to styled HTML and sends emails as multipart/alternative (text/plain + text/html) so recipients see rich formatting in HTML-capable clients while plain-text clients fall back gracefully. - Add _markdown_to_html_email() with inline CSS for email client compat (Gmail strips <style> blocks, so all styling is per-element) - Add _attach_body() and _create_body_part() helpers used by all 3 send paths (_send_email, _send_email_with_attachment(s)) - Config: platforms.email.html_format (default: true, set false to disable and keep plain-text-only behavior) - Lazy import of markdown library to avoid hard dependency at module load time; gracefully falls back to plain text if unavailable Closes NousResearch#11941
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds optional HTML email formatting by converting Markdown bodies into styled HTML and attaching both plain-text and HTML parts to outgoing emails.
Changes:
- Introduced Markdown→HTML conversion with an inline-styled HTML wrapper template.
- Added
platforms.email.html_formatconfig toggle (default enabled). - Refactored email body attachment logic to use
multipart/alternative(and nested alternative parts for attachments).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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) | ||
| return _HERMES_EMAIL_HTML_TEMPLATE.format(body=html) |
| _HERMES_EMAIL_HTML_TEMPLATE = """\ | ||
| <!DOCTYPE html> | ||
| <html> | ||
| <head><meta charset="utf-8"></head> | ||
| <body style="margin:0;padding:0;background:#f4f4f7;"> | ||
| <div style="max-width:680px;margin:0 auto;background:#ffffff; | ||
| font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; | ||
| font-size:15px;line-height:1.6;color:#2d3748;padding:32px;"> | ||
| {body} | ||
| <div style="margin-top:32px;padding-top:16px;border-top:1px solid #e2e8f0; | ||
| font-size:12px;color:#a0aec0;"> | ||
| Sent by <strong>Hermes Agent</strong> | ||
| </div> | ||
| </div> | ||
| </body> | ||
| </html> | ||
| """ |
| # 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) | ||
| return _HERMES_EMAIL_HTML_TEMPLATE.format(body=html) |
| ("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;"'), |
| def _attach_body(self, msg: MIMEMultipart, body: str) -> None: | ||
| """Attach body as plain text + optional HTML to a message.""" | ||
| msg.attach(MIMEText(body, "plain", "utf-8")) | ||
| if self._html_format: | ||
| try: | ||
| html = _markdown_to_html_email(body) | ||
| msg.attach(MIMEText(html, "html", "utf-8")) | ||
| except Exception as e: | ||
| logger.warning("[Email] HTML conversion failed, sending plain only: %s", e) | ||
|
|
||
| def _create_body_part(self, body: str) -> MIMEMultipart: | ||
| """Create a multipart/alternative body part (for use inside multipart/mixed).""" | ||
| alt = MIMEMultipart("alternative") | ||
| alt.attach(MIMEText(body, "plain", "utf-8")) | ||
| if self._html_format: | ||
| try: | ||
| html = _markdown_to_html_email(body) | ||
| alt.attach(MIMEText(html, "html", "utf-8")) | ||
| except Exception as e: | ||
| logger.warning("[Email] HTML conversion failed, sending plain only: %s", e) | ||
| return alt |
1. Remove 'pre code' selector (doesn't match HTML structure).
Use two-pass regex to override <code> styling inside <pre> blocks.
2. Deduplicate _attach_body / _create_body_part — extract shared
_attach_parts() helper with exc_info=True for tracebacks.
3. Replace str.format() with str.replace('{body}', html) to avoid
breakage when Markdown body contains { } braces (e.g., code).
4. Add _PRE_CODE_OVERRIDE regex to correctly handle <code> inside
<pre> blocks (Python-Markdown generates <pre><code>...</code>).</pre>
Addresses review from @Copilot on PR NousResearch#46619.
Copilot Review Fixes (from #46642)Applied these fixes in my duplicate PR — contributing them here: 1. Regex: style= anywhere in tagCurrent code matches style= only as first attribute. Tags like # Current (broken for existing style= not-first):
html = re.sub(rf'<{tag}(\s|>)', rf'<{tag} {style}\1', html)
# Fixed (checks [^>]* for style= anywhere):
html = re.sub(rf'<{tag}(?![^>]*style=)(\s|>)', rf'<{tag} {style}\1', html)2. Markdown validation in initAuto-disable HTML if markdown not installed (one warning, not per-send): self._html_format = extra.get('html_format', True)
if self._html_format:
try:
import markdown # noqa: F401
except ImportError:
logger.warning('[Email] markdown not installed — HTML disabled')
self._html_format = False3. Security commentDocument why no bleach (self-to-self, config kill-switch): # SECURITY NOTE: We intentionally do NOT sanitize with bleach 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: false4. Naming
Verified
|
|
Superseded by #54107 (clean PR targeting the new plugin path at plugins/platforms/email/adapter.py, with consolidated HTML helpers and no bundled unrelated changes). |
Add HTML email rendering to _send_email_with_attachment and _send_email_with_attachments. Previously only _send_email supported multipart/alternative with HTML; attachment paths sent plain text only. - Add _attach_body/_create_body_part/_attach_parts helpers - Add _style_html_email with inline CSS for Gmail/Outlook compat - Add _HTML_PREFIX/_HERMES_EMAIL_FOOTER HTML wrapper templates - Add html_format config option (default: true, opt-out: false) - Both attachment paths now use _create_body_part for HTML support - _send_email uses _attach_body (consolidated, no duplicated logic) Lazy markdown import — adapter works without markdown installed. Graceful fallback: conversion failure → plain text + warning. Supersedes NousResearch#46619 (old gateway path) and NousResearch#54073 (bundled scope). Refs: NousResearch#11941, NousResearch#36853
|
Superseded by #73294 — ported to |
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), so HTML-capable clients render rich formatting while plain-text clients fall back gracefully.Addresses: Closes #11941 (2 reactions), also related to #25439.
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 3 send paths use new helpers:
_send_emailuses_attach_body(msg, body)— plain + HTML parts onMIMEMultipart("alternative")_send_email_with_attachmentuses_create_body_part(body)— wrapped inmultipart/alternativeinsidemultipart/mixed_send_email_with_attachments— same wrapper patternConfig opt-out:
platforms.email.html_format: falsedisables HTML and keeps plain-text-only behavior (default:true).Graceful fallback: if
markdownis not installed or conversion fails, plain text is sent as before.Why inline CSS instead of style blocks
Gmail (both web and mobile) strips
<style>tags from emails. Every style must be on the element itself. This is a well-known email development constraint.Design decisions
import markdown: Avoids hard dependency — email adapter loads even ifmarkdownis not installed. Falls back to plain text.<style>blocks are stripped.html_format: truedefault: Rich email is the better default. Users who want plain-only can opt out.MIMEMultipart("alternative"): Standard email multipart for plain+HTML — all clients understand it.Changes Made
gateway/platforms/email.py:_HERMES_EMAIL_HTML_TEMPLATE— responsive HTML wrapper_HERMES_EMAIL_STYLES— inline CSS map for 17 HTML elements_markdown_to_html_email()— Markdown to HTML to styled HTMLEmailAdapter._attach_body()— attach plain+HTML to a messageEmailAdapter._create_body_part()— wrap body for multipart/mixed (attachments)__init__: readhtml_formatfromconfig.extra(default:true)_send_email: useMIMEMultipart("alternative")+_attach_body_send_email_with_attachment: use_create_body_part_send_email_with_attachments: use_create_body_partHow to Test
Checklist
gateway/platforms/email.pytouched (1 file, 84 insertions, 4 deletions)pyproject.tomlhtml_format: false)feat(email): ...)email.mime+markdownlibrary, no OS-specific codeMIME Structure
With
html_format: true(default):With attachments:
With
html_format: false:Note on markdown dependency
The
markdownlibrary is a lightweight pure-Python package (~100KB, no transitive deps, widely used by Django/MkDocs/Jupyter). It is lazily imported — the email adapter loads and works even ifmarkdownis not installed (falls back to plain text). If the maintainers prefer, it can be added topyproject.tomlas an exact-pinned dependency or moved to an optional extra.