Skip to content

fix(email): HTML emails sent as plain text — two critical bugs - #36853

Open
chtse53 wants to merge 1 commit into
NousResearch:mainfrom
chtse53:fix/html-email-rendering
Open

fix(email): HTML emails sent as plain text — two critical bugs#36853
chtse53 wants to merge 1 commit into
NousResearch:mainfrom
chtse53:fix/html-email-rendering

Conversation

@chtse53

@chtse53 chtse53 commented Jun 1, 2026

Copy link
Copy Markdown

Problem

Cron job emails containing HTML (daily briefings, book summaries) were sent as text/plain, causing email clients to display raw HTML tags instead of rendering the HTML content.

Two bugs caused this:

Bug 1: gateway/platforms/email.py_attach_body()

  • rfind("</html>") picked the last closing tag, but models sometimes produce duplicate </html> tags with garbage text between them
  • For HTML fragments (no </html>), only stripped narrow "Cronjob Response" footer patterns — model commentary like "The previous response was already complete..." leaked through into the HTML body

Bug 2: tools/send_message_tool.py_send_email()

  • Regex had ^ anchor: ^\s*(?:<!DOCTYPE\s+html|<html[\s>]).match() only detected HTML at position 0
  • Cron model outputs always have preamble text before HTML, so this path always fell through to text/plain

Fix

email.py — new _attach_body() helper

  • Added _HTML_RE regex matching <!DOCTYPE html>, <html>, and common block-level tags (no ^ anchor)
  • Preamble stripping: removes any text before the first HTML tag
  • find() instead of rfind() for </html> — uses first occurrence to avoid duplicate-tag garbage
  • Fragment commentary stripping: for HTML fragments with no </html>, walks backwards from the last closing block-level tag to detect and strip trailing model commentary
  • Sends multipart/alternative with both text/plain (auto-generated fallback) and text/html

send_message_tool.py — matching HTML detection

  • Changed _HTML_PATTERN to use .search() (no ^ anchor)
  • Added preamble stripping, find() for </html>, and block-tag fragment cleanup — same logic as email.py
  • Both code paths now produce identical multipart/alternative emails

Testing

Verified with actual cron output (15K-char daily briefing = HTML fragment, 24K-char book summary = HTML document with duplicate closing tags). Both now correctly detected as HTML, preamble stripped, trailing model commentary stripped, sent as multipart/alternative.

Problem: Cron job emails containing HTML (daily briefings, book summaries)
were sent as text/plain, causing email clients to display raw HTML tags
instead of rendering the HTML content. Two bugs caused this:

1. gateway/platforms/email.py _attach_body():
   - rfind('</html>') picked the LAST closing tag, but models sometimes
     produce duplicate </html> tags with garbage text between them
   - For HTML fragments (no </html>), only stripped narrow 'Cronjob
     Response' footer patterns — model commentary like 'The previous
     response was already complete...' leaked through into the HTML body
   - Fix: use find() for </html> (first occurrence), and for fragments
     walk backwards from the last closing block-level tag to detect and
     strip trailing model commentary (pure prose after </div>, </p>, etc.)

2. tools/send_message_tool.py _send_email():
   - Regex had ^ anchor: ^\s*(?:<!DOCTYPE\s+html|<html[\s>])
   - .match() only detected HTML at position 0 — cron model outputs
     ALWAYS have preamble text before HTML, so this ALWAYS fell through
     to text/plain
   - Fix: use .search() (no ^ anchor), add preamble stripping, match
     fragment cleanup logic from email.py, and use find() not rfind()

Both paths now produce identical multipart/alternative emails with clean
HTML + plain-text fallback, no model commentary leakage.
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery platform/email Email (IMAP/SMTP) adapter P3 Low — cosmetic, nice to have labels Jun 1, 2026
@liuhao1024

Copy link
Copy Markdown
Contributor

Clean implementation. The HTML detection regex covers common document openings (DOCTYPE, html) and block-level tags. The preamble stripping uses .search() to handle cron/model text before HTML. For fragments, walking backwards from block-level close tags correctly identifies trailing commentary. The plain-text fallback generation via tag stripping is straightforward. No issues found.

1 similar comment
@liuhao1024

Copy link
Copy Markdown
Contributor

Clean implementation. The HTML detection regex covers common document openings (DOCTYPE, html) and block-level tags. The preamble stripping uses .search() to handle cron/model text before HTML. For fragments, walking backwards from block-level close tags correctly identifies trailing commentary. The plain-text fallback generation via tag stripping is straightforward. No issues found.

@swissly

swissly commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Overlap note: This PR targets the old gateway/platforms/email.py path. The email adapter has been migrated to plugins/platforms/email/adapter.py.

I have PR #54107 which adds HTML rendering (multipart/alternative with markdown→HTML conversion) to the new plugin path's attachment send paths. Your HTML detection and preamble stripping logic (Bug 2 in send_message_tool.py) is complementary — my PR only covers the email adapter, not the send_message tool.

Happy to coordinate: I can rebase #54107 after yours lands, or we can combine approaches.

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
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the concrete HTML-detection and cleanup work. The underlying plaintext behavior is still present on current main, but this branch no longer reaches the active delivery code.

Problems

  • gateway/platforms/email.py was renamed to plugins/platforms/email/adapter.py by 560010547. Current direct and attachment sends still attach MIMEText(body, "plain", "utf-8") at plugins/platforms/email/adapter.py:949, :1063, and :1143.
  • The _send_email helper changed by this PR was removed from tools/send_message_tool.py; current out-of-process/cron delivery dispatches through the plugin registry (tools/send_message_tool.py:743-752) to _standalone_send, which still creates MIMEText(message, "plain", "utf-8") at plugins/platforms/email/adapter.py:1219.
  • The PR adds no regression tests for outbound MIME structure or the malformed/preamble HTML cases.

Suggested changes

  • Port the shared HTML body handling into plugins/platforms/email/adapter.py and apply it to all four active send paths, including _standalone_send.
  • Add outbound MIME tests in tests/gateway/test_email.py for the reported document and fragment cases.

Automated hermes-sweeper review.

@alt-glitch alt-glitch added comp/plugins Plugin system and bundled plugins needs-decision Awaiting maintainer decision before any implementation and removed comp/gateway Gateway runner, session dispatch, delivery labels Jul 13, 2026
swissly added a commit to swissly/hermes-agent that referenced this pull request Jul 13, 2026
…ping)

Addresses sweeper feedback on NousResearch#36853: when the body already contains
HTML (from cron/model output), detect it, strip preamble text and
trailing commentary, and send as multipart/alternative.

Changes:
- Add _HTML_RE and _BLOCK_CLOSE_RE regex constants for HTML detection
- Update _attach_parts() to check for pre-existing HTML before
  converting markdown to HTML
- Strip preamble before first HTML tag
- Use find() (first </html>) not rfind() for duplicate tag handling
- Strip trailing model commentary after last closing block tag
- Generate plain-text fallback by stripping HTML tags

Tests:
- HTML document with preamble → preamble stripped
- HTML fragment (no </html>) → commentary stripped
- Duplicate </html> → first occurrence used
- Plain text → not detected as HTML

Refs: NousResearch#36853, NousResearch#54107
@swissly

swissly commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Ported to new adapter path: PR #54107 now covers all 4 active send paths in plugins/platforms/email/adapter.py:

  1. _send_email (line ~1030) — direct send
  2. _send_email_with_attachments (line ~1167) — multiple attachments
  3. _send_email_with_attachment (line ~1247) — single attachment
  4. _standalone_send (line ~1328) — cron/gateway delivery

Latest push adds the preamble/fragment handling from this PR:

  • _HTML_RE regex (no ^ anchor) for HTML detection in pre-existing body content
  • Preamble stripping before first HTML tag
  • find() (first </html>) instead of rfind() for duplicate tag handling
  • Fragment commentary stripping (walk backwards from last closing block tag)
  • Plain-text fallback generation

Regression tests added: tests/test_email_html.py — 17 tests covering:

  • HTML document with preamble → stripped
  • HTML fragment (no </html>) → commentary stripped
  • Duplicate </html> → first occurrence used
  • Plain text → not detected as HTML
  • multipart/alternative MIME structure for all send paths

Supersedes #36853 (old gateway/platforms/email.py path). Also refs #34603.

@alt-glitch alt-glitch added the sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages label Jul 13, 2026
@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 13, 2026
@swissly

swissly commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Overlap note (2026-08-03): this PR addresses the same topic (HTML email delivery). PR #73294 (swissly) consolidates Markdown-to-HTML rendering for ALL 4 send paths + allowlist sanitizer + 20 tests, and was just rebased onto current main (commit 9529ee30). Please review #73294 for consolidation rather than duplicating send-path fixes.

@swissly

swissly commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Consolidation update from #73294 (swissly, 2026-08-03):

Your HTML-body handling (preamble/postamble trimming) is valuable — I've ported it into the consolidated PR #73294 on the current adapter path. Context: your diff targets gateway/platforms/email.py, which was removed from main by the bundled-plugin migration (commit 5600105) — that file no longer exists, so this PR cannot merge as-is.

What was ported (with attribution, commit 592c9b2fc): _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, applied in the already-HTML branch of _markdown_to_html_email in plugins/platforms/email/adapter.py. 5 regression tests added.

#73294 now covers: Markdown-to-HTML rendering + allowlist sanitizer + preamble/postamble trimming + SMTP_SSL 465 fix, all 4 send paths, 61 tests green. Recommend closing #36853 as consolidated — happy to answer questions or adjust the port.

swissly added a commit to swissly/hermes-agent that referenced this pull request Aug 3, 2026
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.
@alt-glitch alt-glitch added comp/gateway Gateway runner, session dispatch, delivery comp/tools Tool registry, model_tools, toolsets and removed comp/plugins Plugin system and bundled plugins sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 4, 2026
swissly added a commit to swissly/hermes-agent that referenced this pull request Aug 12, 2026
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.
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 comp/tools Tool registry, model_tools, toolsets needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have platform/email Email (IMAP/SMTP) adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants