Skip to content

feat(email): Markdown-to-HTML rendering for outbound emails - #46619

Closed
swissly wants to merge 2 commits into
NousResearch:mainfrom
swissly:feat/email-html-markdown
Closed

feat(email): Markdown-to-HTML rendering for outbound emails#46619
swissly wants to merge 2 commits into
NousResearch:mainfrom
swissly:feat/email-html-markdown

Conversation

@swissly

@swissly swissly commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds automatic Markdown-to-HTML rendering for all outbound emails. Sends emails as multipart/alternative with both text/plain (raw body) and text/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

  1. _markdown_to_html_email(body) converts Markdown to HTML using the markdown library (tables, fenced code, nl2br), then injects inline CSS per element (because Gmail strips <style> blocks).

  2. All 3 send paths use new helpers:

    • _send_email uses _attach_body(msg, body) — plain + HTML parts on MIMEMultipart("alternative")
    • _send_email_with_attachment uses _create_body_part(body) — wrapped in multipart/alternative inside multipart/mixed
    • _send_email_with_attachments — same wrapper pattern
  3. Config opt-out: platforms.email.html_format: false disables HTML and keeps plain-text-only behavior (default: true).

  4. Graceful fallback: if markdown is 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

  • Lazy import markdown: Avoids hard dependency — email adapter loads even if markdown is not installed. Falls back to plain text.
  • Inline CSS per element: Gmail compatibility — <style> blocks are stripped.
  • html_format: true default: 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:
    • Add _HERMES_EMAIL_HTML_TEMPLATE — responsive HTML wrapper
    • Add _HERMES_EMAIL_STYLES — inline CSS map for 17 HTML elements
    • Add _markdown_to_html_email() — Markdown to HTML to styled HTML
    • Add EmailAdapter._attach_body() — attach plain+HTML to a message
    • Add EmailAdapter._create_body_part() — wrap body for multipart/mixed (attachments)
    • Modify __init__: read html_format from config.extra (default: true)
    • Modify _send_email: use MIMEMultipart("alternative") + _attach_body
    • Modify _send_email_with_attachment: use _create_body_part
    • Modify _send_email_with_attachments: use _create_body_part

How to Test

# Unit: syntax + import check
python3 -c "from gateway.platforms.email import _markdown_to_html_email; print(_markdown_to_html_email('**bold** and *italic*'))"

# E2E: send a message via email, check:
# - Gmail: renders headings, bold, tables, code blocks
# - Apple Mail: same
# - Thunderbird: same
# - Plain-text client (mutt): sees raw Markdown (unchanged)

Checklist

  • Only gateway/platforms/email.py touched (1 file, 84 insertions, 4 deletions)
  • Lazy import — no hard dependency added to pyproject.toml
  • Config opt-out available (html_format: false)
  • Graceful fallback: markdown missing leads to plain text; conversion error leads to plain text + warning
  • All 3 send paths updated
  • Conventional Commits format (feat(email): ...)
  • Cross-platform: pure Python email.mime + markdown library, no OS-specific code
  • No tool schema change

MIME Structure

With html_format: true (default):

multipart/alternative
+-- text/plain   (raw Markdown body)
+-- text/html    (styled HTML with inline CSS)

With attachments:

multipart/mixed
+-- multipart/alternative
|   +-- text/plain
|   +-- text/html
+-- application/octet-stream (attachment)

With html_format: false:

text/plain   (unchanged from current behavior)

Note on markdown dependency

The markdown library 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 if markdown is not installed (falls back to plain text). If the maintainers prefer, it can be added to pyproject.toml as an exact-pinned dependency or moved to an optional extra.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_format config 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.

Comment thread gateway/platforms/email.py Outdated
Comment on lines +93 to +100
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)
Comment on lines +53 to +69
_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>
"""
Comment thread gateway/platforms/email.py Outdated
# 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)
Comment thread gateway/platforms/email.py Outdated
Comment on lines +85 to +87
("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;"'),
Comment thread gateway/platforms/email.py Outdated
Comment on lines +687 to +707
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.
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery platform/email Email (IMAP/SMTP) adapter labels Jun 15, 2026
@swissly

swissly commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Copilot Review Fixes (from #46642)

Applied these fixes in my duplicate PR — contributing them here:

1. Regex: style= anywhere in tag

Current code matches style= only as first attribute. Tags like <p class="x" style="..."> get duplicate style.

# 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 init

Auto-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 = False

3. Security comment

Document 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: false

4. Naming

_HERMES_EMAIL_HTML_TEMPLATE is fine, but consider _HTML_PREFIX + _HTML_FOOTER for the split parts (avoids .replace() on template).

Verified

  • Syntax check passed
  • multipart/alternative structure correct
  • RFC-compliant email structure

@swissly

swissly commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #54073 which targets the new plugins/platforms/email/adapter.py path (the email adapter was migrated from gateway/platforms/ since this PR was opened).

Closing in favor of #54073.

@swissly

swissly commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

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).

swissly added a commit to swissly/hermes-agent that referenced this pull request Jul 3, 2026
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
@swissly

swissly commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #73294 — ported to plugins/platforms/email/adapter.py (live path) with all 4 send paths including _standalone_send.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have platform/email Email (IMAP/SMTP) adapter type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: HTML email support for email gateway (multipart/alternative + Markdown rendering)

3 participants