diff --git a/plugins/platforms/email/adapter.py b/plugins/platforms/email/adapter.py index aabcfb318ede..db3f543fe458 100644 --- a/plugins/platforms/email/adapter.py +++ b/plugins/platforms/email/adapter.py @@ -227,18 +227,27 @@ def _decode_header_value(raw: str) -> str: return " ".join(_safe_decode(part, charset) if isinstance(part, bytes) else part for part, charset in parts) -def _first_body_part(msg: email_lib.message.Message, content_type: str) -> str: +def _first_body_part(msg: email_lib.message.Message, content_type: str, *, skip_blank: bool = False) -> str: """Decoded text of the first non-attachment part of *content_type*, or ''.""" - for part in msg.walk(): - if "attachment" in str(part.get("Content-Disposition", "")) or part.get_content_type() != content_type: + pending = [msg] + while pending: + part = pending.pop() + # Do not descend into attached messages: their children have no disposition. + if part.get_content_disposition() == "attachment": continue - if payload := part.get_payload(decode=True): - return _safe_decode(payload, part.get_content_charset()) + if part.is_multipart(): + pending.extend(reversed(part.get_payload())) + elif part.get_content_type() == content_type and (payload := part.get_payload(decode=True)): + text = _safe_decode(payload, part.get_content_charset()) + if not skip_blank or text.strip(): + return text return "" -def _extract_text_body(msg: email_lib.message.Message) -> str: - """Extract the plain-text body from a potentially multipart email.""" +def _extract_text_body(msg: email_lib.message.Message, *, preserve_html: bool = False) -> str: + """Prefer decoded HTML when opted in; otherwise retain plain-text extraction.""" + if preserve_html and (html := _first_body_part(msg, "text/html", skip_blank=True)): + return html if msg.is_multipart(): html = _first_body_part(msg, "text/html") return _first_body_part(msg, "text/plain") or (_strip_html(html) if html else "") @@ -440,6 +449,7 @@ def __init__(self, config: PlatformConfig): self._smtp_tls_verify = tls_verify("EMAIL_SMTP_TLS_VERIFY", "smtp_tls_verify") self._poll_interval = _esecret_int("EMAIL_POLL_INTERVAL", 15) self._skip_attachments = extra.get("skip_attachments", False) # platforms.email.skip_attachments + self._preserve_html = is_truthy_value(extra.get("preserve_html"), default=False) # Require an authenticated From: domain (SPF/DKIM/DMARC) before trusting it for authorization # (GHSA-rxqh-5572-8m77). Default ON; opt out via require_authenticated_sender: false / EMAIL_TRUST_FROM_HEADER=true. if "require_authenticated_sender" in extra: @@ -671,7 +681,7 @@ def _parse_fetched_message(self, uid: bytes, raw_email: "bytes | bytearray") -> sender_authenticated, auth_reason = _verify_sender_authentication(msg, sender_addr, authserv_id=self._authserv_id) return {"uid": uid, "sender_addr": sender_addr, "sender_name": sender_name, "subject": subject, "message_id": msg.get("Message-ID", ""), "in_reply_to": msg.get("In-Reply-To", ""), - "body": _extract_text_body(msg), + "body": _extract_text_body(msg, preserve_html=self._preserve_html), "attachments": _extract_attachments(msg, skip_attachments=self._skip_attachments), "date": msg.get("Date", ""), "sender_authenticated": sender_authenticated, "auth_reason": auth_reason} diff --git a/tests/gateway/test_email_preserve_html.py b/tests/gateway/test_email_preserve_html.py new file mode 100644 index 000000000000..6f812a177c83 --- /dev/null +++ b/tests/gateway/test_email_preserve_html.py @@ -0,0 +1,88 @@ +"""Inbound formatting opt-in and plaintext compatibility (regression for #23695).""" + +from email.mime.multipart import MIMEMultipart +from email.mime.message import MIMEMessage +from email.mime.text import MIMEText +from unittest.mock import AsyncMock + +import pytest + + + +HTML = '

Rejected & revised

' +PLAIN = 'Rejected & revised' + + +def _message(kind): + if kind == 'single': + msg = MIMEText(HTML, 'html', 'utf-8') + elif kind == 'plain': + msg = MIMEText(PLAIN, 'plain', 'utf-8') + elif kind == 'empty': + msg = MIMEText('', 'html', 'utf-8') + else: + msg = MIMEMultipart('mixed') + attachment = MIMEText('Not the body', 'html', 'utf-8') + attachment.add_header('Content-Disposition', 'attachment', filename='report.html') + if kind == 'mixed_disposition': + attachment.replace_header('Content-Disposition', 'Attachment; filename=report.html') + elif kind == 'nested_attachment': + attachment = MIMEMessage(MIMEText('Not the body', 'html', 'utf-8')) + attachment.add_header('Content-Disposition', 'attachment', filename='forwarded.eml') + msg.attach(attachment) + alternatives = MIMEMultipart('alternative') + alternatives.attach(MIMEText(PLAIN, 'plain', 'utf-8')) + if kind == 'blank_before_html': + alternatives.attach(MIMEText(' \n\t ', 'html', 'utf-8')) + html = {'empty_html': '', 'blank_html': ' \n\t '}.get(kind, HTML) + alternatives.attach(MIMEText(html, 'html', 'utf-8')) + msg.attach(alternatives) + msg['From'] = 'author@example.test' + msg['Subject'] = 'Re: Review' + msg['Message-ID'] = '' + if kind == 'unknown_charset': + alternatives.get_payload()[1].set_param('charset', 'unknown-codec') + return msg.as_bytes() + + +@pytest.mark.parametrize('flag', [None, False, 'false', True, 'true']) +@pytest.mark.parametrize('kind', ['single', 'multipart', 'plain', 'empty', 'empty_html', 'unknown_charset', + 'mixed_disposition', 'nested_attachment', 'blank_html', 'blank_before_html']) +def test_received_body_preserves_formatting_only_when_enabled(flag, kind): + from gateway.config import PlatformConfig + from plugins.platforms.email.adapter import EmailAdapter + + options = {'skip_attachments': True} + if flag is not None: + options['preserve_html'] = flag + adapter = EmailAdapter(PlatformConfig.from_dict(options)) + parsed = adapter._parse_fetched_message(b'1', _message(kind)) + expected = '' if kind == 'empty' else PLAIN + if flag in (True, 'true') and kind not in ('plain', 'empty', 'empty_html', 'blank_html'): + expected = HTML + assert parsed['body'] == expected + assert parsed['attachments'] == [] + + +@pytest.mark.asyncio +async def test_yaml_option_reaches_dispatched_email(tmp_path, monkeypatch): + from gateway.config import load_gateway_config, Platform + from plugins.platforms.email.adapter import EmailAdapter + + monkeypatch.setenv('HERMES_HOME', str(tmp_path)) + monkeypatch.setenv('EMAIL_ALLOWED_USERS', 'author@example.test') + config_path = tmp_path / 'config.yaml' + for enabled in (True, False, True): + config_path.write_text( + 'platforms:\n email:\n enabled: true\n' + f' preserve_html: {str(enabled).lower()}\n' + ' skip_attachments: true\n' + ' require_authenticated_sender: false\n', encoding='utf-8', + ) + adapter = EmailAdapter(load_gateway_config().platforms[Platform.EMAIL]) + adapter.handle_message = AsyncMock() + parsed = adapter._parse_fetched_message(b'1', _message('multipart')) + await adapter._dispatch_message(parsed) + event = adapter.handle_message.call_args.args[0] + assert event.text == (HTML if enabled else PLAIN) + assert event.source.user_id == 'author@example.test' diff --git a/website/docs/user-guide/messaging/email.md b/website/docs/user-guide/messaging/email.md index 16ac4251c04a..1689fe071b62 100644 --- a/website/docs/user-guide/messaging/email.md +++ b/website/docs/user-guide/messaging/email.md @@ -138,6 +138,25 @@ The adapter polls the IMAP inbox for UNSEEN messages at a configurable interval - **Self-messages** are filtered out to prevent reply loops - **Automated/noreply senders** are silently ignored — `noreply@`, `mailer-daemon@`, `bounce@`, `no-reply@`, and emails with `Auto-Submitted`, `Precedence: bulk`, or `List-Unsubscribe` headers +### Preserving Incoming HTML + +By default, Hermes prefers the plain-text body and strips tags from HTML-only +messages. To let the agent see formatting such as colors, font sizes, and inline +styles, opt in through `config.yaml`: + +```yaml +platforms: + email: + preserve_html: true +``` + +This prefers the decoded `text/html` body, excluding attachment parts, and falls +back to plain text when no non-empty HTML body exists. The HTML is passed to the +agent as text, not rendered; it is untrusted email content and may include hidden +text or instructions. No remote images or stylesheets are fetched. Set the option +to `false` (the default) to restore plain-text extraction. This does not change +outbound replies, sender authorization, or attachment handling. + ### Sending Replies Replies are sent via SMTP with proper email threading: