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
24 changes: 21 additions & 3 deletions gateway/platforms/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,24 @@ def _strip_html(html: str) -> str:
return text.strip()


def _is_html(text: str) -> bool:
"""Return True if text appears to be HTML content."""
stripped = text.strip()
if stripped.startswith(("<!DOCTYPE", "<html", "<HTML", "<!doctype")):
return True
return bool(re.search(r"<(p|div|br|h[1-6]|ul|ol|table|html)\b", stripped, re.IGNORECASE))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This detector deliberately excludes valid inline-only HTML (<a>, <span>, <b>), which the added tests also assert. Those bodies will still be sent as text/plain; please either broaden and test the detection contract or narrow the feature claim.


def _build_body_part(body: str) -> MIMEText | MIMEMultipart:
"""Return a MIME part for the body β€” multipart/alternative if HTML detected."""
if _is_html(body):
alt = MIMEMultipart("alternative")
alt.attach(MIMEText(_strip_html(body), "plain", "utf-8"))
alt.attach(MIMEText(body, "html", "utf-8"))
return alt
return MIMEText(body, "plain", "utf-8")


def _extract_email_address(raw: str) -> str:
"""Extract bare email address from 'Name <addr>' format."""
match = re.search(r"<([^>]+)>", raw)
Expand Down Expand Up @@ -509,7 +527,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"))
msg.attach(_build_body_part(body))

smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
try:
Expand Down Expand Up @@ -619,7 +637,7 @@ def _send_email_with_attachments(
msg["Message-ID"] = msg_id

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

for file_path in file_paths:
p = Path(file_path)
Expand Down Expand Up @@ -700,7 +718,7 @@ def _send_email_with_attachment(
msg["Message-ID"] = msg_id

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

# Attach file
p = Path(file_path)
Expand Down
129 changes: 129 additions & 0 deletions tests/gateway/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,135 @@ def test_send_email_tool_not_configured(self):
self.assertIn("not configured", result["error"])


class TestHtmlDetection(unittest.TestCase):
"""Test _is_html detection and _build_body_part multipart/alternative logic."""

def test_plain_text_not_detected_as_html(self):
from gateway.platforms.email import _is_html
self.assertFalse(_is_html("Hello, this is plain text."))
self.assertFalse(_is_html("No tags here.\nJust lines."))
self.assertFalse(_is_html(""))

def test_doctype_detected(self):
from gateway.platforms.email import _is_html
self.assertTrue(_is_html("<!DOCTYPE html><html><body>Hi</body></html>"))
self.assertTrue(_is_html("<!doctype html>\n<html>"))

def test_html_tag_detected(self):
from gateway.platforms.email import _is_html
self.assertTrue(_is_html("<html><body>Content</body></html>"))
self.assertTrue(_is_html("<HTML><BODY>Content</BODY></HTML>"))

def test_block_level_tags_detected(self):
from gateway.platforms.email import _is_html
self.assertTrue(_is_html("<p>A paragraph</p>"))
self.assertTrue(_is_html("<div>A div</div>"))
self.assertTrue(_is_html("Hello<br>World"))
self.assertTrue(_is_html("<h1>Title</h1>"))
self.assertTrue(_is_html("<h6>Heading</h6>"))
self.assertTrue(_is_html("<ul><li>Item</li></ul>"))
self.assertTrue(_is_html("<ol><li>Item</li></ol>"))
self.assertTrue(_is_html("<table><tr><td>Cell</td></tr></table>"))

def test_inline_tags_not_detected(self):
from gateway.platforms.email import _is_html
self.assertFalse(_is_html("Use <b>bold</b> text"))
self.assertFalse(_is_html("<span>inline</span>"))
self.assertFalse(_is_html("<a href='x'>link</a>"))

def test_angle_brackets_in_prose_not_detected(self):
from gateway.platforms.email import _is_html
self.assertFalse(_is_html("if x < 10 and y > 5"))
self.assertFalse(_is_html("use <your_name> as placeholder"))

def test_leading_whitespace_ignored(self):
from gateway.platforms.email import _is_html
self.assertTrue(_is_html(" \n <!DOCTYPE html><html>"))
self.assertTrue(_is_html("\n\n<html><body>X</body></html>"))

def test_build_body_part_plain_text(self):
from gateway.platforms.email import _build_body_part
part = _build_body_part("Hello, plain text.")
self.assertEqual(part.get_content_type(), "text/plain")
self.assertIn("Hello, plain text.", part.get_payload(decode=True).decode())

def test_build_body_part_html_returns_alternative(self):
from gateway.platforms.email import _build_body_part
html = "<html><body><h1>Hello</h1><p>World</p></body></html>"
part = _build_body_part(html)
self.assertEqual(part.get_content_type(), "multipart/alternative")
subparts = part.get_payload()
self.assertEqual(len(subparts), 2)
self.assertEqual(subparts[0].get_content_type(), "text/plain")
self.assertEqual(subparts[1].get_content_type(), "text/html")
# Plain part should have tags stripped
plain_body = subparts[0].get_payload(decode=True).decode()
self.assertNotIn("<h1>", plain_body)
self.assertIn("Hello", plain_body)
# HTML part should preserve original
html_body = subparts[1].get_payload(decode=True).decode()
self.assertIn("<h1>Hello</h1>", html_body)

def test_build_body_part_with_div_content(self):
from gateway.platforms.email import _build_body_part
html = "<div>Report: <p>Revenue is up 20%</p></div>"
part = _build_body_part(html)
self.assertEqual(part.get_content_type(), "multipart/alternative")

def test_send_email_uses_multipart_alternative_for_html(self):
"""_send_email should produce multipart/alternative when body is HTML."""
from gateway.config import PlatformConfig
with patch.dict(os.environ, {
"EMAIL_ADDRESS": "hermes@test.com",
"EMAIL_PASSWORD": "secret",
"EMAIL_IMAP_HOST": "imap.test.com",
"EMAIL_SMTP_HOST": "smtp.test.com",
}):
from gateway.platforms.email import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))

html_body = "<html><body><h1>Report</h1><p>Details here.</p></body></html>"

with patch("smtplib.SMTP") as mock_smtp:
mock_server = MagicMock()
mock_smtp.return_value = mock_server

adapter._send_email("user@test.com", html_body, None)

sent_msg = mock_server.send_message.call_args[0][0]
# The outer message is multipart/mixed, containing a multipart/alternative
payloads = sent_msg.get_payload()
alt_part = payloads[0] if isinstance(payloads, list) else sent_msg
self.assertEqual(alt_part.get_content_type(), "multipart/alternative")
sub = alt_part.get_payload()
self.assertEqual(sub[0].get_content_type(), "text/plain")
self.assertEqual(sub[1].get_content_type(), "text/html")

def test_send_email_plain_text_unchanged(self):
"""_send_email should send plain MIMEText when body is not HTML."""
from gateway.config import PlatformConfig
with patch.dict(os.environ, {
"EMAIL_ADDRESS": "hermes@test.com",
"EMAIL_PASSWORD": "secret",
"EMAIL_IMAP_HOST": "imap.test.com",
"EMAIL_SMTP_HOST": "smtp.test.com",
}):
from gateway.platforms.email import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))

with patch("smtplib.SMTP") as mock_smtp:
mock_server = MagicMock()
mock_smtp.return_value = mock_server

adapter._send_email("user@test.com", "Just plain text.", None)

sent_msg = mock_server.send_message.call_args[0][0]
payloads = sent_msg.get_payload()
# Should be a single text/plain part inside the multipart/mixed
text_part = payloads[0] if isinstance(payloads, list) else sent_msg
self.assertEqual(text_part.get_content_type(), "text/plain")


class TestSmtpConnectionCleanup(unittest.TestCase):
"""Verify SMTP connections are closed even when send_message raises."""

Expand Down