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
')
+ 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("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
'))
+ 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 ( Hello world Hi world markup (Codex feedback).
+ self.assertNotIn(" ", parts["text/plain"])
+ self.assertNotIn("0: 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('
'))
+
+ def test_attach_always_multipart_alternative(self):
+ from email.mime.multipart import MIMEMultipart
+ from gateway.platforms.email import _attach_body
+ for body in ("Digest
/
", 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."""