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'] = '