diff --git a/plugins/platforms/email/adapter.py b/plugins/platforms/email/adapter.py index d983fd81c79d5..0ea0423d8d703 100644 --- a/plugins/platforms/email/adapter.py +++ b/plugins/platforms/email/adapter.py @@ -33,6 +33,7 @@ from gateway.config import Platform, PlatformConfig from utils import is_truthy_value from gateway.platforms._shared import get_scoped_secret as _get_secret, coerce_port, decode_json_list_literal, send_error +from plugins.platforms.email.markdown_html import markdown_to_email_html logger = logging.getLogger(__name__) @@ -436,6 +437,14 @@ def _extract_attachments(msg: email_lib.message.Message, skip_attachments: bool return attachments +def _body_alternative(body: str) -> MIMEMultipart: + """Plain part first, HTML second: a client that cannot render HTML keeps the original text.""" + alternative = MIMEMultipart("alternative") + alternative.attach(MIMEText(body, "plain", "utf-8")) + alternative.attach(MIMEText(markdown_to_email_html(body), "html", "utf-8")) + return alternative + + def _attach_file(msg: MIMEMultipart, path: Path, filename: str) -> None: """Attach *path* to *msg* as base64 application/octet-stream.""" with open(path, "rb") as f: @@ -871,7 +880,7 @@ def _new_reply(self, to_addr: str, body: str, reply_to_msg_id: Optional[str] = N ("Date", formatdate(localtime=True)), ("Message-ID", msg_id)): msg[key] = value if body or attach_empty_body: - msg.attach(MIMEText(body, "plain", "utf-8")) + msg.attach(_body_alternative(body)) return msg, msg_id, subject def _smtp_send(self, msg: MIMEMultipart) -> None: @@ -972,7 +981,7 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f if not all([address, password, smtp_host]): return send_error("Email not configured (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_SMTP_HOST required)") try: - msg = MIMEText(message, "plain", "utf-8") + msg = _body_alternative(message) for key, value in (("From", address), ("To", chat_id), ("Subject", t("platform.email.standalone_subject")), ("Date", formatdate(localtime=True))): msg[key] = value server = _open_smtp(smtp_host, smtp_port, smtp_security, _tls_context(smtp_tls_verify, smtp_host), smtplib.SMTP, smtplib.SMTP_SSL) diff --git a/plugins/platforms/email/markdown_html.py b/plugins/platforms/email/markdown_html.py new file mode 100644 index 0000000000000..c5460ebf78f62 --- /dev/null +++ b/plugins/platforms/email/markdown_html.py @@ -0,0 +1,115 @@ +"""Render agent Markdown as inline-styled email HTML. + +The email adapter sends ``text/plain`` only, so Markdown the agent writes for chat +(``**bold**``, ``## Headings``, bullets) reaches a mail reader as literal asterisks and +hashes. This converts the subset the agent actually emits into simple HTML, which the +adapter attaches as a ``text/html`` alternative *beside* the unchanged plain part: +HTML-capable clients render it, and every other client still gets the original text. +""" + +from __future__ import annotations + +import html +import re + +# Anchored block markers. A heading requires its space, so "#hashtag" stays prose. +_HEADING = re.compile(r"^(#{1,6})\s+(.*)$") +_BULLET = re.compile(r"^[-*+]\s+(.*)$") +_NUMBERED = re.compile(r"^\d+[.)]\s+(.*)$") + +_BOLD = re.compile(r"\*\*(.+?)\*\*") +_ITALIC = re.compile(r"(?()]+)""") + +_SENTENCE_END = ".,;:!?" +_HEADING_STYLE = { + 2: "margin:18px 0 8px;font-size:1.25em;font-weight:600", + 3: "margin:16px 0 8px;font-size:1.15em;font-weight:600", + 4: "margin:14px 0 6px;font-size:1.05em;font-weight:600", + 5: "margin:12px 0 6px;font-size:1em;font-weight:600", + 6: "margin:12px 0 6px;font-size:1em;font-weight:600", +} +_PARAGRAPH_STYLE = "margin:0 0 12px" +_LIST_STYLE = "margin:0 0 12px;padding-left:22px" +_ITEM_STYLE = "margin:0 0 4px" + + +def _linkify(match: re.Match) -> str: + """Wrap a bare URL, leaving sentence punctuation outside the anchor.""" + url, trailing = match.group(1), "" + while url and url[-1] in _SENTENCE_END: + trailing = url[-1] + trailing + url = url[:-1] + return f'{url}{trailing}' if url else match.group(0) + + +def _inline(text: str) -> str: + """Escape, then apply inline Markdown. Escaping first means content cannot inject markup.""" + text = html.escape(text) + text = _CODE.sub(r"\1", text) + text = _BOLD.sub(r"\1", text) + text = _ITALIC.sub(r"\1", text) + text = _LINK.sub(r'\1', text) + return _BARE_URL.sub(_linkify, text) + + +def markdown_to_email_html(text: str) -> str: + """Convert the Markdown subset the agent emits into inline-styled email HTML.""" + if not text.strip(): + return "" + blocks: list[str] = [] + paragraph: list[str] = [] + items: list[str] = [] + list_tag = "" + + def flush_paragraph() -> None: + if paragraph: + blocks.append(f'

{_inline(" ".join(paragraph))}

') + paragraph.clear() + + def flush_list() -> None: + nonlocal list_tag + if items: + rendered = "".join(f'
  • {item}
  • ' for item in items) + blocks.append(f'<{list_tag} style="{_LIST_STYLE}">{rendered}') + items.clear() + list_tag = "" + + def start_list(tag: str) -> None: + nonlocal list_tag + if list_tag != tag: + flush_list() + list_tag = tag + + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + flush_paragraph() + flush_list() + continue + if heading := _HEADING.match(stripped): + flush_paragraph() + flush_list() + # h1 stays reserved for the message subject line. + tag = min(len(heading.group(1)) + 1, 6) + blocks.append(f'{_inline(heading.group(2))}') + continue + if bullet := _BULLET.match(stripped): + flush_paragraph() + start_list("ul") + items.append(_inline(bullet.group(1))) + continue + if numbered := _NUMBERED.match(stripped): + flush_paragraph() + start_list("ol") + items.append(_inline(numbered.group(1))) + continue + flush_list() + paragraph.append(stripped) + + flush_paragraph() + flush_list() + return "\n".join(blocks) diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py index 9b272d4fa3024..f45ce03007da1 100644 --- a/tests/gateway/test_email.py +++ b/tests/gateway/test_email.py @@ -1451,7 +1451,10 @@ def test_oversized_cron_output_is_delivered_as_one_whole_email(): adapter = EmailAdapter(PlatformConfig(enabled=True)) sent = [] smtp = MagicMock() - smtp.send_message.side_effect = lambda msg: sent.append(msg.get_payload()[0].get_payload(decode=True).decode()) + # The body rides inside a multipart/alternative, so walk to the plain part. + smtp.send_message.side_effect = lambda msg: sent.append( + next(p.get_payload(decode=True).decode() for p in msg.walk() if p.get_content_type() == "text/plain") + ) adapter._connect_smtp = lambda: smtp content = "\n\n".join(f"line {i} " + "x" * 200 for i in range(60)) payload = DeliveryRouter(GatewayConfig())._cap_oversized_output(adapter, content, "job") diff --git a/tests/gateway/test_email_html_body.py b/tests/gateway/test_email_html_body.py new file mode 100644 index 0000000000000..fb05ee2445dd9 --- /dev/null +++ b/tests/gateway/test_email_html_body.py @@ -0,0 +1,121 @@ +"""Outgoing email carries a ``text/html`` alternative, and the plain part is unchanged. + +The adapter sent ``text/plain`` only, so Markdown the agent writes for chat +(``**bold**``, ``## Headings``) arrived in a mail reader as literal asterisks and hashes. +Both send paths must now attach an HTML alternative, and a client that cannot render HTML +must still receive the byte-identical plain text it received before. +""" + +import asyncio +import os +import tempfile +import unittest +from unittest.mock import patch, MagicMock + +# Markdown the agent emits, plus untrusted markup that must not survive as live HTML. +_BODY = """## Bottom line + +The **PJM** order stands, per [the filing](https://example.gov/pjm). + +## Major stories + +1. DOE issues an emergency order +2. FERC opens a docket + +- Watch the Senate markup +- Track the filing +""" + +_SMTP_ENV = { + "EMAIL_ADDRESS": "hermes@test.com", + "EMAIL_PASSWORD": "secret", + "EMAIL_IMAP_HOST": "imap.test.com", + "EMAIL_SMTP_HOST": "smtp.test.com", +} + + +def _assert_alternative(test: unittest.TestCase, msg) -> None: + """The contract both send paths share: an HTML alternative beside an unchanged plain part.""" + test.assertTrue( + any(p.get_content_type() == "multipart/alternative" for p in msg.walk()), + f"no multipart/alternative in {msg.get_content_type()}", + ) + bodies = { + part.get_content_type(): part.get_payload(decode=True).decode("utf-8") + for part in msg.walk() + if part.get_content_type() in ("text/plain", "text/html") + } + + # Clients that cannot render HTML keep the exact text they got before the fix. + test.assertEqual(bodies["text/plain"], _BODY) + + html = bodies["text/html"] + test.assertIn("PJM", html) # "**PJM**" became bold + test.assertNotIn("##", html) # no literal Markdown left behind + test.assertNotIn("**", html) + # Content is escaped, so a body can never inject live markup. + test.assertNotIn("