fix(email): preserve fetched messages across IMAP retry

Keep messages already collected in the current poll cycle when a stale
IMAP connection dies mid-fetch, so retrying once does not drop emails
that were already parsed and marked seen. Add a regression test covering
a mid-cycle disconnect after the first message succeeds.

Made-with: Cursor
This commit is contained in:
Xubin Ren
2026-03-20 18:47:54 +00:00
committed by Xubin Ren
parent b16bd2d9a8
commit 542455109d
2 changed files with 70 additions and 11 deletions

View File

@@ -282,13 +282,26 @@ class EmailChannel(BaseChannel):
dedupe: bool,
limit: int,
) -> list[dict[str, Any]]:
try:
return self._fetch_messages_once(search_criteria, mark_seen, dedupe, limit)
except Exception as exc:
if not self._is_stale_imap_error(exc):
raise
logger.warning("Email IMAP connection went stale, retrying once: {}", exc)
return self._fetch_messages_once(search_criteria, mark_seen, dedupe, limit)
messages: list[dict[str, Any]] = []
cycle_uids: set[str] = set()
for attempt in range(2):
try:
self._fetch_messages_once(
search_criteria,
mark_seen,
dedupe,
limit,
messages,
cycle_uids,
)
return messages
except Exception as exc:
if attempt == 1 or not self._is_stale_imap_error(exc):
raise
logger.warning("Email IMAP connection went stale, retrying once: {}", exc)
return messages
def _fetch_messages_once(
self,
@@ -296,9 +309,10 @@ class EmailChannel(BaseChannel):
mark_seen: bool,
dedupe: bool,
limit: int,
) -> list[dict[str, Any]]:
messages: list[dict[str, Any]],
cycle_uids: set[str],
) -> None:
"""Fetch messages by arbitrary IMAP search criteria."""
messages: list[dict[str, Any]] = []
mailbox = self.config.imap_mailbox or "INBOX"
if self.config.imap_use_ssl:
@@ -336,6 +350,8 @@ class EmailChannel(BaseChannel):
continue
uid = self._extract_uid(fetched)
if uid and uid in cycle_uids:
continue
if dedupe and uid and uid in self._processed_uids:
continue
@@ -378,6 +394,8 @@ class EmailChannel(BaseChannel):
}
)
if uid:
cycle_uids.add(uid)
if dedupe and uid:
self._processed_uids.add(uid)
# mark_seen is the primary dedup; this set is a safety net
@@ -393,8 +411,6 @@ class EmailChannel(BaseChannel):
except Exception:
pass
return messages
@classmethod
def _is_stale_imap_error(cls, exc: Exception) -> bool:
message = str(exc).lower()