Repository navigation
fix(email): send a text/html alternative so Markdown renders - #138554
daniel5simmons wants to merge 1 commit into
Conversation
The email adapter sent text/plain only, so Markdown the agent writes for chat (**bold**, ## headings, bullets) reached a mail reader as literal asterisks and hashes. Every other platform has a Markdown strategy — Telegram converts to MarkdownV2, Slack to mrkdwn, Matrix to HTML, SMS strips it — and email was the only adapter with none, because email's native rich-text format is HTML rather than Markdown. Both send paths now attach a multipart/alternative carrying rendered HTML beside the unchanged plain part: - _new_reply() covers every in-process send: replies, images and documents - _standalone_send() covers cron delivery — the sibling call path The plain part is byte-identical to what was sent before, so a client that cannot render HTML is unaffected. Content is escaped before any Markdown is applied, so a body cannot inject live markup. The converter adds no dependencies and produces inline-styled HTML (mail clients ignore stylesheets). Matrix solves the same problem with the third-party `markdown` library, which is not a declared Hermes dependency — it is imported there opportunistically under suppress(ImportError) with a regex fallback. Extracting a shared Markdown-to-HTML converter would be the better long-term shape and is deliberately left as a follow-up rather than scope creep here. Testing: - tests/gateway/test_email_html_body.py (new) — 3 behaviour contracts, all proven red on the base by reverting the adapter: the plain part is unchanged, the HTML alternative renders, and untrusted markup is escaped - tests/gateway/test_email.py — one existing test asserted msg.get_payload()[0] was the plain part; that index is now the alternative container, so it walks to the text/plain part instead. Its contract is unchanged. Note: tests/gateway/test_email.py has 20 pre-existing failures on a clean origin/main checkout, all in the sender-authentication area (TestDispatchDefersToGatewayAuthorization / authserv_id). They are unrelated to this change and reproduce with it reverted; flagging in case they are unexpected.
Correction to the "pre-existing failures" noteMy PR description says:
That number is wrong, and I want to retract it rather than leave a misleading note in review. What I measured: 20 failures in a locally bootstrapped environment I had created for this work (a fresh So those 20 failures were an artifact of my own environment, not a property of For completeness, the real accounting in the standard checkout, on this branch: which is what the branch now carries: 0 failures in Related finding (separate, not in this PR)While verifying the above I hit a second, unrelated bug in the same adapter: Reproduced against a mailbox with 319,004 messages: This is independent of this PR and I will file it separately; flagging it here only because it is the same file and reviewers may see it. Nothing in this PR touches the IMAP paths. |
Correction to the correction (I got this wrong twice — sorry)My earlier comment retracted the "20 pre-existing failures" claim, saying they were an artifact of my environment and that pristine What I actually did was measure the v0.21.3 checkout, which is where this work started, see 0 failures there, and conclude the 20 were mine. v0.21.3 is ~15,600 commits behind this PR's base. The correct comparison is against the branch the PR targets. Measured properly, on Identical counts, and the failing set is the same both ways — all in the sender-authentication area ( Summary of the three states, so this is unambiguous:
The 20 are pre-existing, unrelated to this change, and reproduce with it reverted on the current base. Apologies for the churn — I should have compared against the PR's base the first time rather than against whatever checkout happened to be in front of me. |
What breaks
Email is the only platform whose native rich-text format is HTML rather than Markdown. Every other adapter has a Markdown strategy — Telegram converts to MarkdownV2, Slack to mrkdwn, Matrix to HTML, SMS strips it — so agent output renders there. The email adapter has none, so Markdown the agent writes for chat reaches a mail reader as literal asterisks and hashes.
To reproduce: have the agent write anything with
**bold**or a##heading and deliver it to email. The mail arrives as## Bottom line/The **PJM** order. A daily cron briefing makes it unmissable; short conversational replies hide it.Manifests at exactly two lines, on both send paths:
plugins/platforms/email/adapter.py:874—msg.attach(MIMEText(body, "plain", "utf-8"))in_new_reply(), which every in-process send funnels through (replies,send_image,send_multiple_images,send_document)plugins/platforms/email/adapter.py:975—msg = MIMEText(message, "plain", "utf-8")in_standalone_send(), the out-of-process cron pathThe fix
Both paths now attach a
multipart/alternativecarrying rendered HTML beside the unchanged plain part:_new_reply()keeps itsMIMEMultipart()wrapper, so attachments stay siblings of the alternative (multipart/mixed → multipart/alternative → (plain, html))_standalone_send()has nothing to attach, so the alternative is the top levelmarkdown_html.pyconverts the subset the agent actually emits — headings, bold, italic, inline code, bullets, numbered lists,[links](url)and bare URLs — into inline-styled HTML (mail clients ignore stylesheets). Content is escaped before any Markdown is applied, so a body cannot inject live markup.The plain part is byte-identical to what was sent before this change. The HTML part is purely additive; a client that cannot render HTML is unaffected.
Testing
tests/gateway/test_email_html_body.py(new) — 3 behaviour contracts:_standalone_send(): the sibling call path carries the alternative tooAll 3 were proven red on the base by reverting the adapter — they fail, they are not tests written to match the implementation.
Notes for review
test_oversized_cron_output_is_delivered_as_one_whole_emailasserted onmsg.get_payload()[0], which is now the alternative container rather than the plain part, so it walks to thetext/plainpart instead. Its contract ("the whole cron payload arrives as one untruncated message") is unchanged, and it passes on the base and with the fix. Flagging it because an author editing a test to go green deserves scrutiny.markdownis not a declared dependency. Matrix solves the same problem with the third-partymarkdownlibrary, imported there opportunistically undersuppress(ImportError)with a regex fallback. Extracting a shared Markdown-to-HTML converter would be the better long-term shape; it is deliberately left as a follow-up rather than scope creep here. This converter adds no dependencies.tests/gateway/test_email.pyon a cleanorigin/maincheckout, all in the sender-authentication area (TestDispatchDefersToGatewayAuthorization, authserv_id). They reproduce with this change reverted and are unrelated — flagging in case they are unexpected.Scope note: this PR is the HTML alternative only. A cron-delivered mail also gets a generic subject and keeps the
Cronjob Response:wrapper, which is a separate concern affecting other platforms and is not included here.