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).
This commit is contained in:
CharmingGroot 2026-07-02 02:35:32 -07:00 committed by Teknium
parent c43aa6301d
commit 88bd1c01e1
3 changed files with 142 additions and 4 deletions

View file

@ -673,7 +673,25 @@ class EmailAdapter(BasePlatformAdapter):
if status != "OK":
continue
raw_email = msg_data[0][1]
# IMAP fetch can return unexpected structures (e.g. a
# single bytes item instead of a list of tuples). Guard
# against IndexError / TypeError so one malformed response
# doesn't abort the batch — the UID is already in
# _seen_uids, so an abort would permanently skip the
# remaining messages in this batch.
try:
raw_email = msg_data[0][1]
except (IndexError, TypeError):
logger.warning(
"[Email] Unexpected IMAP response structure for UID %s, skipping",
uid,
)
continue
if not isinstance(raw_email, (bytes, bytearray)):
logger.warning(
"[Email] Non-bytes IMAP payload for UID %s, skipping", uid
)
continue
msg = email_lib.message_from_bytes(raw_email)
sender_raw = msg.get("From", "")
@ -890,6 +908,16 @@ class EmailAdapter(BasePlatformAdapter):
logger.error("[Email] Send failed to %s: %s", chat_id, e)
return SendResult(success=False, error=str(e))
def _message_id_domain(self) -> str:
"""Domain part for generated Message-IDs.
EMAIL_ADDRESS may lack an ``@`` (misconfiguration); fall back to
``localhost`` instead of crashing send with an IndexError.
"""
if "@" in self._address:
return self._address.rsplit("@", 1)[-1] or "localhost"
return "localhost"
def _send_email(
self,
to_addr: str,
@ -915,7 +943,7 @@ class EmailAdapter(BasePlatformAdapter):
msg["References"] = original_msg_id
msg["Date"] = formatdate(localtime=True)
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._message_id_domain()}>"
msg["Message-ID"] = msg_id
msg.attach(MIMEText(body, "plain", "utf-8"))
@ -1028,7 +1056,7 @@ class EmailAdapter(BasePlatformAdapter):
msg["References"] = original_msg_id
msg["Date"] = formatdate(localtime=True)
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._message_id_domain()}>"
msg["Message-ID"] = msg_id
if body:
@ -1108,7 +1136,7 @@ class EmailAdapter(BasePlatformAdapter):
msg["References"] = original_msg_id
msg["Date"] = formatdate(localtime=True)
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._message_id_domain()}>"
msg["Message-ID"] = msg_id
if body:

View file

@ -46,6 +46,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"aqdrgg19@gmail.com": "VolodymyrBg", # PR #2861 salvage (webhook: drop the unused full request payload from retained _delivery_info entries — up to ~1MB dead weight per delivery for the 1h idempotency TTL)
"ohyes9711@gmail.com": "CharmingGroot", # PR #2794 salvage (email: guard msg_data[0][1] against malformed IMAP fetch structures so one bad response can't abort the batch and permanently lose seen-marked messages; Message-ID domain falls back to localhost when EMAIL_ADDRESS lacks '@')
"sahibzada@fastino.ai": "sahibzada-allahyar", # PR #39227 salvage (desktop: configured terminal.cwd overrides a stale remembered workspace-cwd localStorage value when no session is active; #38855)
"jvsantos.cunha@gmail.com": "plcunha", # PR #55300 salvage (gateway: record child gateway peer metadata after a compression session-id rotation and repoint stale sessions.json compression-parent entries to the recovered live child; consolidated in the compression-routing-integrity salvage)
"jakepresent1@gmail.com": "jakepresent", # PR #55721 salvage (gateway: identity-guard stale in-flight compression splits — a late run may publish its compressed child only if its run generation is still current and the session key still points at the run's original parent, so an old run can't overwrite a newer /new or moved binding)

View file

@ -0,0 +1,109 @@
"""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()