hermes-agent/tests/gateway/test_email_robustness.py
CharmingGroot 88bd1c01e1 fix(email): harden adapter against malformed IMAP responses
Salvage of #2794 by @CharmingGroot, ported to the relocated
plugins/platforms/email/adapter.py:

- Guard raw_email = msg_data[0][1] against IndexError/TypeError and
  non-bytes payloads. UIDs are added to _seen_uids before fetch, so an
  exception mid-batch permanently skipped every remaining message in
  the batch — now the bad message is logged and skipped instead.
- Message-ID domain generation falls back to 'localhost' when
  EMAIL_ADDRESS lacks '@' (now via a shared _message_id_domain() helper
  covering all 3 send paths; the PR fixed 2 of 3).
2026-07-02 03:12:53 -07:00

109 lines
3.7 KiB
Python

"""Email adapter robustness against malformed IMAP responses (salvage of #2794).
Validates that:
- Malformed IMAP fetch responses are skipped instead of aborting the batch
(UIDs are marked seen before fetch, so an abort permanently loses messages)
- Message-ID generation handles a missing '@' in EMAIL_ADDRESS
"""
import os
import unittest
import uuid
from email.mime.text import MIMEText
from unittest.mock import MagicMock, patch
def _make_adapter(address="hermes@test.com"):
from gateway.config import PlatformConfig
with patch.dict(os.environ, {
"EMAIL_ADDRESS": address,
"EMAIL_PASSWORD": "secret",
"EMAIL_IMAP_HOST": "imap.test.com",
"EMAIL_SMTP_HOST": "smtp.test.com",
}):
from plugins.platforms.email.adapter import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))
return adapter
def _raw_email(sender="user@test.com", subject="Hello"):
msg = MIMEText("Test body", "plain", "utf-8")
msg["From"] = sender
msg["Subject"] = subject
msg["Message-ID"] = f"<{uuid.uuid4().hex[:8]}@test.com>"
return msg.as_bytes()
class TestImapResponseGuard(unittest.TestCase):
"""_fetch_new_messages skips messages with unexpected IMAP structure."""
def _fetch_with(self, fetch_responses):
adapter = _make_adapter()
uids = b" ".join(
str(i + 1).encode() for i in range(len(fetch_responses))
)
fetch_iter = iter(fetch_responses)
def uid_handler(command, *args):
if command == "search":
return ("OK", [uids])
if command == "fetch":
return next(fetch_iter)
return ("NO", [])
mock_imap = MagicMock()
mock_imap.uid.side_effect = uid_handler
with patch("imaplib.IMAP4_SSL", return_value=mock_imap):
return adapter._fetch_new_messages()
def test_normal_response_parses(self):
results = self._fetch_with([("OK", [(b"1 (RFC822 {123}", _raw_email())])])
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["sender_addr"], "user@test.com")
def test_none_element_skipped(self):
results = self._fetch_with([("OK", [None])])
self.assertEqual(results, [])
def test_empty_list_skipped(self):
results = self._fetch_with([("OK", [])])
self.assertEqual(results, [])
def test_bare_bytes_element_skipped(self):
# Single bytes item instead of a (header, payload) tuple
results = self._fetch_with([("OK", [b"not-a-tuple"])])
self.assertEqual(results, [])
def test_non_bytes_payload_skipped(self):
results = self._fetch_with([("OK", [(b"1", None)])])
self.assertEqual(results, [])
def test_malformed_does_not_abort_batch(self):
"""A malformed response mid-batch must not lose the messages after it."""
results = self._fetch_with([
("OK", [None]), # UID 1 malformed
("OK", [(b"2 (RFC822 {123}", _raw_email())]), # UID 2 fine
])
self.assertEqual(len(results), 1)
class TestMessageIdDomain(unittest.TestCase):
"""Message-ID generation tolerates EMAIL_ADDRESS without '@'."""
def test_normal_address(self):
adapter = _make_adapter("hermes@example.org")
self.assertEqual(adapter._message_id_domain(), "example.org")
def test_address_without_at(self):
adapter = _make_adapter("not-an-email")
self.assertEqual(adapter._message_id_domain(), "localhost")
def test_address_trailing_at(self):
adapter = _make_adapter("weird@")
self.assertEqual(adapter._message_id_domain(), "localhost")
if __name__ == "__main__":
unittest.main()