diff --git a/mcp_servers/email_server.py b/mcp_servers/email_server.py index 8438577..9382624 100644 --- a/mcp_servers/email_server.py +++ b/mcp_servers/email_server.py @@ -666,7 +666,7 @@ def _read_email(uid=None, message_id=None, folder="INBOX", account=None): conn.logout() return {"error": "No UID or Message-ID provided"} - status, msg_data = conn.uid("FETCH", _b(uid), "(RFC822)") + status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])") if status != "OK": conn.logout() return {"error": f"Failed to fetch email UID {uid}"} @@ -855,7 +855,7 @@ def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None): """Reply to an existing email by UID. Threads via In-Reply-To/References.""" conn = _imap_connect(account) conn.select(folder, readonly=True) - status, msg_data = conn.uid("FETCH", _b(uid), "(RFC822)") + status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])") conn.logout() if status != "OK" or not msg_data or not msg_data[0]: return {"error": f"Failed to fetch email UID {uid}"} @@ -1033,7 +1033,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None): """Extract a specific attachment to disk and return its local path.""" conn = _imap_connect(account) conn.select(folder, readonly=True) - status, msg_data = conn.uid("FETCH", _b(uid), "(RFC822)") + status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])") conn.logout() if status != "OK": return {"error": f"Failed to fetch email UID {uid}"} diff --git a/tests/test_icloud_imap_full_fetch.py b/tests/test_icloud_imap_full_fetch.py new file mode 100644 index 0000000..0e58acc --- /dev/null +++ b/tests/test_icloud_imap_full_fetch.py @@ -0,0 +1,41 @@ +"""Regression for issue #1961 — read_email (and reply_to_email, +download_attachment) failed on iCloud IMAP accounts. + +iCloud's IMAP server silently ignores the legacy bare `RFC822` fetch item: a +`UID FETCH (RFC822)` returns status OK but only `(UID )` with no body +tuple, so the parse treats the message as "not found" — even though list_emails +works (it uses `RFC822.HEADER`, which iCloud honours). The modern +`BODY.PEEK[]` item is honoured by iCloud and Gmail alike and doesn't set \\Seen. + +The fix is an IMAP-protocol-string change exercised only against a live server, +so it's guarded at the source here (per CONTRIBUTING's "guard at source" note): +the three full-message fetches must use BODY.PEEK[], and no bare (RFC822) full +fetch may remain. The header/uid fetches must be left untouched so listing keeps +working. +""" +import re +from pathlib import Path + +SRC = (Path(__file__).resolve().parent.parent / "mcp_servers/email_server.py").read_text(encoding="utf-8") + + +def _full_fetches(): + # every conn.uid("FETCH", ..., "") call's fetch item + return re.findall(r'conn\.uid\(\s*"FETCH"\s*,[^,]+,\s*"([^"]+)"\s*\)', SRC) + + +def test_full_message_fetches_use_body_peek_not_bare_rfc822(): + items = _full_fetches() + assert items, "no conn.uid FETCH calls found — test anchor stale" + # No bare (RFC822) full-message fetch may remain (it breaks iCloud). + assert "(RFC822)" not in items, f"a bare (RFC822) full fetch remains: {items}" + # The full-message reads now use BODY.PEEK[] — at least the 3 known sites. + assert items.count("(BODY.PEEK[])") >= 3, f"expected >=3 BODY.PEEK[] fetches: {items}" + + +def test_header_and_uid_fetches_preserved(): + items = _full_fetches() + # Listing relies on RFC822.HEADER (iCloud honours it) — must stay. + assert "(RFC822.HEADER)" in items, "RFC822.HEADER fetch (used by listing) must be preserved" + # UID-only probes must stay as-is. + assert "(UID)" in items, "(UID) probe fetch must be preserved"