Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions plugins/platforms/email/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
115 changes: 115 additions & 0 deletions plugins/platforms/email/markdown_html.py
Original file line number Diff line number Diff line change
@@ -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"(?<!\*)\*([^*\n]+)\*(?!\*)")
_CODE = re.compile(r"`([^`\n]+)`")
_LINK = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)")
# The lookbehind skips a URL already inside an href or a wrapped parenthetical.
_BARE_URL = re.compile(r"""(?<!["'=(])(https?://[^\s<>()]+)""")

_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'<a href="{url}">{url}</a>{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"<code>\1</code>", text)
text = _BOLD.sub(r"<strong>\1</strong>", text)
text = _ITALIC.sub(r"<em>\1</em>", text)
text = _LINK.sub(r'<a href="\2">\1</a>', 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'<p style="{_PARAGRAPH_STYLE}">{_inline(" ".join(paragraph))}</p>')
paragraph.clear()

def flush_list() -> None:
nonlocal list_tag
if items:
rendered = "".join(f'<li style="{_ITEM_STYLE}">{item}</li>' for item in items)
blocks.append(f'<{list_tag} style="{_LIST_STYLE}">{rendered}</{list_tag}>')
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'<h{tag} style="{_HEADING_STYLE[tag]}">{_inline(heading.group(2))}</h{tag}>')
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)
5 changes: 4 additions & 1 deletion tests/gateway/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
121 changes: 121 additions & 0 deletions tests/gateway/test_email_html_body.py
Original file line number Diff line number Diff line change
@@ -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 <script>alert(1)</script> 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("<h3", html) # "## " became a heading
test.assertIn("<strong>PJM</strong>", 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("<script>", html)
test.assertIn("&lt;script&gt;", html)


class TestReplyCarriesHtmlAlternative(unittest.TestCase):
"""The in-process send path (replies, attachments, images, documents)."""

def _adapter(self):
from gateway.config import PlatformConfig
from plugins.platforms.email.adapter import EmailAdapter
with patch.dict(os.environ, _SMTP_ENV):
return EmailAdapter(PlatformConfig(enabled=True))

def _send(self, adapter, fn, *args):
with patch("smtplib.SMTP") as mock_smtp:
server = MagicMock()
mock_smtp.return_value = server
fn(*args)
return server.send_message.call_args[0][0]

def test_reply_carries_html_alternative(self):
adapter = self._adapter()
sent = self._send(adapter, adapter._send_email, "user@test.com", _BODY, None)
_assert_alternative(self, sent)

def test_attachment_rides_beside_the_alternative(self):
"""An attachment is a sibling of the alternative, not a part inside it."""
adapter = self._adapter()
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
f.write(b"attachment bytes")
path = f.name
try:
sent = self._send(adapter, adapter._send_email_with_attachment,
"user@test.com", _BODY, path)
finally:
os.unlink(path)

self.assertEqual(sent.get_content_type(), "multipart/mixed")
_assert_alternative(self, sent)
self.assertTrue(any(
"attachment" in str(p.get("Content-Disposition", "")) for p in sent.walk()
))


class TestStandaloneSenderCarriesHtmlAlternative(unittest.TestCase):
"""The out-of-process path cron delivery uses — the sibling call path."""

def test_standalone_send_carries_html_alternative(self):
from gateway.config import PlatformConfig
from plugins.platforms.email import adapter as email_adapter

with patch.dict(os.environ, _SMTP_ENV):
with patch.object(email_adapter, "_open_smtp") as open_smtp:
server = MagicMock()
open_smtp.return_value = server
result = asyncio.run(
email_adapter._standalone_send(PlatformConfig(enabled=True), "user@test.com", _BODY)
)

self.assertTrue(result.get("success"), result)
sent = server.send_message.call_args[0][0]
# Nothing to attach, so the alternative is the top level — not wrapped in a mixed part.
self.assertEqual(sent.get_content_type(), "multipart/alternative")
_assert_alternative(self, sent)