diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index 0fffb82d0b94..b964ab3b8520 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -17,6 +17,7 @@ import asyncio import email as email_lib +import html as html_lib import imaplib import logging import os @@ -161,10 +162,59 @@ def _extract_text_body(msg: email_lib.message.Message) -> str: def _strip_html(html: str) -> str: - """Naive HTML tag stripper for fallback text extraction.""" - text = re.sub(r"", "\n", html, flags=re.IGNORECASE) + """Convert an HTML fragment to readable plain text. + + Used for the inbound text extraction *and* the outbound ``text/plain`` + alternative of an HTML body, so it keeps the parts that matter without + markup: link targets become ``label (url)`` so URLs survive, and block/list + boundaries become newlines so lists and tables don't collapse into one run. + """ + # Drop

Digest

" + "") + self.assertEqual(result.strip(), "Digest") + self.assertNotIn("color:red", result) + self.assertNotIn("alert", result) + + def test_strip_html_preserves_image_reference(self): + # Codex feedback: image-only body must not become blank. + from gateway.platforms.email import _strip_html + result = _strip_html('chart') + self.assertIn("chart", result) + self.assertIn("https://e.com/c.png", 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("

Digest

Item

")) + self.assertTrue(_is_html_body('Read more')) + + 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 world")) + self.assertTrue(_is_html_body("Hello world")) + self.assertTrue(_is_html_body("Line one
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('')) + self.assertTrue(_is_html_body("
code block
")) + + 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 //

. + self.assertFalse(_is_html_body("ac")) + self.assertFalse(_is_html_body("50: pass")) + # Comparisons against multi-letter tag-named variables must not match + # either: a real tag closes (

) or has an attribute (
0: pass")) + self.assertFalse(_is_html_body("if x0: pass")) + self.assertFalse(_is_html_body("if x0: pass")) + + def test_detects_tags_with_attributes(self): + from gateway.platforms.email import _is_html_body + self.assertTrue(_is_html_body('
x
')) + self.assertTrue(_is_html_body('')) + + def test_attach_always_multipart_alternative(self): + from email.mime.multipart import MIMEMultipart + from gateway.platforms.email import _attach_body + for body in ("

Hello world

", "Just plain text"): + msg = MIMEMultipart() + _attach_body(msg, body) + self.assertEqual(sorted(self._parts(msg)), ["text/html", "text/plain"]) + + def test_html_body_verbatim_in_html_part_stripped_in_plain(self): + from email.mime.multipart import MIMEMultipart + from gateway.platforms.email import _attach_body + msg = MIMEMultipart() + _attach_body(msg, "

Digest

Hi world

") + parts = self._parts(msg) + # HTML part keeps the markup verbatim … + self.assertIn("world", parts["text/html"]) + # … but the plain part is tag-stripped, so text-only clients and indexed + # snippets never see raw

/

markup (Codex feedback). + self.assertNotIn("

", parts["text/plain"]) + self.assertNotIn("

", parts["text/plain"]) + self.assertIn("Digest", parts["text/plain"]) + self.assertIn("world", parts["text/plain"]) + + def test_plain_prose_kept_verbatim_in_plain_part(self): + # A reply with no *complete* tag is plain text: plain part is verbatim, + # HTML part escapes it so the brackets show as text rather than render. + from email.mime.multipart import MIMEMultipart + from gateway.platforms.email import _attach_body + msg = MIMEMultipart() + body = "Compare a with white-space:pre-wrap, + # not a bare
 that disables wrapping.
+        from email.mime.multipart import MIMEMultipart
+        from gateway.platforms.email import _attach_body
+        msg = MIMEMultipart()
+        _attach_body(msg, "col1    col2\n  indented line")
+        html = self._parts(msg)["text/html"]
+        self.assertIn("0" must NOT be parsed as an  anchor.
+        from email.mime.multipart import MIMEMultipart
+        from gateway.platforms.email import _attach_body, _is_html_body
+        body = "if x0: pass"
+        self.assertFalse(_is_html_body(body))
+        msg = MIMEMultipart()
+        _attach_body(msg, body)
+        parts = self._parts(msg)
+        self.assertEqual(parts["text/plain"], body)          # sent as plain, verbatim
+        self.assertNotIn("here'))
+        self.assertTrue(_is_html_body("Link: text"))
+
+    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."""