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
26 changes: 18 additions & 8 deletions plugins/platforms/email/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}

Expand Down
88 changes: 88 additions & 0 deletions tests/gateway/test_email_preserve_html.py
Original file line number Diff line number Diff line change
@@ -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 = '<p style="color: red; font-size: 20px">Rejected &amp; revised</p>'
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('<b>Not the body</b>', '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('<b>Not the body</b>', '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'] = '<review@example.test>'
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'
19 changes: 19 additions & 0 deletions website/docs/user-guide/messaging/email.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down