Skip to content
Merged
70 changes: 65 additions & 5 deletions gateway/platforms/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import asyncio
import email as email_lib
import html as html_lib
import imaplib
import logging
import os
Expand Down Expand Up @@ -174,6 +175,67 @@ def _strip_html(html: str) -> str:
return text.strip()


# Whether a body *already* contains HTML markup we should send as-is, versus
# plain text we should HTML-escape before wrapping. This no longer gates whether
# HTML is sent (we always send a multipart/alternative) — it only decides if the
# HTML part is the body verbatim or an escaped+linebreaked version. So a wrong
# guess never makes the email worse than plain text: the plain part is always the
# faithful original, and a misjudged plain body just shows its literal "<tag>" as
# code in the HTML part.
#
# An opening tag only counts when the tag name is followed by a real terminator,
# and single-letter tags are split by how they usually appear so prose
# comparisons don't trip them:
# * ``b``/``i`` (bold/italic, ~never carry attributes) require an immediate
# ``>`` or ``/`` — so "<b>" matches but "a<b and b>c" (whitespace after b)
# does not.
# * ``a`` (anchors almost always have href=) and multi-letter tags accept a
# trailing ``>``, ``/`` or whitespace (for "<a href=...>", "<div class=...>").
_HTML_TAGS_LENIENT = r"html|body|div|p|br|h[1-6]|a|ul|ol|li|table|span|strong|em|img|pre|code|blockquote"
_HTML_TAGS_STRICT = r"b|i" # single-letter, attribute-free in practice
_HTML_CLOSE_TAGS = r"html|body|div|p|h[1-6]|a|ul|ol|li|table|span|strong|em|b|i|pre|code|blockquote"
_HTML_BODY_RE = re.compile(
rf"<\s*(?:{_HTML_TAGS_LENIENT})\s*(?:>|/|\s)"
Comment thread
dizhaky marked this conversation as resolved.
Outdated
Comment thread
dizhaky marked this conversation as resolved.
Outdated
rf"|<\s*(?:{_HTML_TAGS_STRICT})\s*(?:>|/)"
rf"|<\s*/\s*(?:{_HTML_CLOSE_TAGS})\s*>",
re.IGNORECASE,
)


def _is_html_body(body: str) -> bool:
"""Heuristic: is this body already HTML markup (vs. plain text to escape)?"""
return bool(_HTML_BODY_RE.search(body))
Comment thread
dizhaky marked this conversation as resolved.


def _text_to_html(text: str) -> str:
"""Convert a plain-text body to a safe HTML fragment.

Escapes ``< > &`` so literal markup in prose (e.g. a coding-help reply that
mentions ``<div class="card">``) is shown as text rather than rendered, and
turns newlines into ``<br>`` so the layout survives.
"""
return html_lib.escape(text).replace("\n", "<br>\n")
Comment thread
dizhaky marked this conversation as resolved.
Outdated


def _attach_body(msg: MIMEMultipart, body: str) -> None:
"""Attach *body* to *msg* as a ``multipart/alternative``.

Always carries two parts: a ``text/plain`` part that is the body verbatim
(the faithful fallback — never tag-stripped), and a ``text/html`` part.
When the body already looks like HTML it is used as-is; otherwise it is
escaped and line-broken via :func:`_text_to_html`. Sending both parts means
we never have to *guess whether* to send HTML — only how to build the HTML
part — so misdetection can't regress a plain-text email.
"""
if not body:
return
alt = MIMEMultipart("alternative")
alt.attach(MIMEText(body, "plain", "utf-8"))
Comment thread
dizhaky marked this conversation as resolved.
Outdated
html_part = body if _is_html_body(body) else _text_to_html(body)
alt.attach(MIMEText(html_part, "html", "utf-8"))
msg.attach(alt)


def _extract_email_address(raw: str) -> str:
"""Extract bare email address from 'Name <addr>' format."""
match = re.search(r"<([^>]+)>", raw)
Expand Down Expand Up @@ -546,7 +608,7 @@ def _send_email(
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
msg["Message-ID"] = msg_id

msg.attach(MIMEText(body, "plain", "utf-8"))
_attach_body(msg, body)

smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
try:
Expand Down Expand Up @@ -655,8 +717,7 @@ def _send_email_with_attachments(
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
msg["Message-ID"] = msg_id

if body:
msg.attach(MIMEText(body, "plain", "utf-8"))
_attach_body(msg, body)

for file_path in file_paths:
p = Path(file_path)
Expand Down Expand Up @@ -736,8 +797,7 @@ def _send_email_with_attachment(
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
msg["Message-ID"] = msg_id

if body:
msg.attach(MIMEText(body, "plain", "utf-8"))
_attach_body(msg, body)

# Attach file
p = Path(file_path)
Expand Down
93 changes: 93 additions & 0 deletions tests/gateway/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,99 @@ def test_strip_html_entities(self):
self.assertIn("a & b", result)


class TestHtmlBodyDetection(unittest.TestCase):
"""Every email is multipart/alternative; detection only shapes the HTML part."""

@staticmethod
def _parts(msg):
"""Return {content_type: payload_str} for the alternative subparts."""
alt = msg.get_payload()[0]
return {sub.get_content_type(): sub.get_payload(decode=True).decode("utf-8")
for sub in alt.get_payload()}

def test_detects_html_body(self):
from gateway.platforms.email import _is_html_body
self.assertTrue(_is_html_body("<h2>Digest</h2><p>Item</p>"))
self.assertTrue(_is_html_body('Read <a href="http://x">more</a>'))

def test_detects_single_letter_tags(self):
from gateway.platforms.email import _is_html_body
# Real single-letter tags must still be detected as HTML.
self.assertTrue(_is_html_body("Hello <b>world</b>"))
self.assertTrue(_is_html_body("Hello <i>world</i>"))
self.assertTrue(_is_html_body("Line one<br/>Line two"))

def test_detects_standalone_tags(self):
from gateway.platforms.email import _is_html_body
# Codex feedback: image-only / preformatted bodies are valid HTML too.
self.assertTrue(_is_html_body('<img src="http://x/y.png">'))
self.assertTrue(_is_html_body("<pre>code block</pre>"))

def test_plain_text_not_detected_as_html(self):
from gateway.platforms.email import _is_html_body
self.assertFalse(_is_html_body("Plain text, no markup."))
self.assertFalse(_is_html_body("if x < y and y > z: pass"))
self.assertFalse(_is_html_body("I <3 this"))
# Comparisons against single-letter operands must not look like <b>/<i>.
self.assertFalse(_is_html_body("a<b and b>c"))
self.assertFalse(_is_html_body("5<i means five is less than i"))

def test_attach_always_multipart_alternative(self):
from email.mime.multipart import MIMEMultipart
from gateway.platforms.email import _attach_body
for body in ("<p>Hello <strong>world</strong></p>", "Just plain text"):
msg = MIMEMultipart()
_attach_body(msg, body)
self.assertEqual(sorted(self._parts(msg)), ["text/html", "text/plain"])

def test_html_body_used_verbatim_in_html_part(self):
from email.mime.multipart import MIMEMultipart
from gateway.platforms.email import _attach_body
msg = MIMEMultipart()
_attach_body(msg, "<p>Hi <strong>world</strong></p>")
parts = self._parts(msg)
self.assertIn("<strong>world</strong>", parts["text/html"])
# Plain part is the verbatim body (faithful fallback).
self.assertEqual(parts["text/plain"], "<p>Hi <strong>world</strong></p>")

def test_plain_part_always_preserves_literal_text(self):
# Codex feedback: a coding-help reply mentioning literal markup must keep
# the exact text available. The plain part is ALWAYS verbatim, so even if
# the HTML part renders embedded markup, the original is never lost.
from email.mime.multipart import MIMEMultipart
from gateway.platforms.email import _attach_body
msg = MIMEMultipart()
body = 'Use <div class="card">...</div> in your template'
_attach_body(msg, body)
self.assertEqual(self._parts(msg)["text/plain"], body)

def test_prose_with_partial_angle_brackets_is_escaped(self):
# A reply that uses angle brackets but no *complete* tag is plain text:
# it must be escaped in the HTML part, not passed through.
from email.mime.multipart import MIMEMultipart
from gateway.platforms.email import _attach_body
msg = MIMEMultipart()
body = "Compare a<b to verify the sort order"
_attach_body(msg, body)
parts = self._parts(msg)
self.assertEqual(parts["text/plain"], body)
self.assertIn("a&lt;b", parts["text/html"]) # escaped, shown as text

def test_plain_text_newlines_become_breaks(self):
from email.mime.multipart import MIMEMultipart
from gateway.platforms.email import _attach_body
msg = MIMEMultipart()
_attach_body(msg, "line one\nline two")
self.assertIn("<br>", self._parts(msg)["text/html"])

def test_attach_empty_body_is_noop(self):
from email.mime.multipart import MIMEMultipart
from gateway.platforms.email import _attach_body
msg = MIMEMultipart()
_attach_body(msg, "")
self.assertEqual(msg.get_payload(), [])


class TestExtractTextBody(unittest.TestCase):
"""Test email body extraction from different message formats."""

Expand Down
Loading