diff --git a/src/server.py b/src/server.py index 29a4d85..1e9b4ba 100644 --- a/src/server.py +++ b/src/server.py @@ -503,16 +503,26 @@ async def watch_folder( backoff = 5 max_backoff = 60 + # Hoisted across reconnects so a transient disconnect (e.g. iCloud's + # unsolicited FETCH FLAGS during IDLE) doesn't re-baseline the cursor and + # silently drop mail that arrived during the reconnect window. Reset only + # on first run or when the server's UIDVALIDITY changes (which means UIDs + # have been reassigned and the previous cursor is meaningless). + last_seen_uid: Optional[int] = None + last_uidvalidity: Optional[int] = None + while not stop_event.is_set(): client = None try: client = await asyncio.to_thread(get_imap_client, account) - await asyncio.to_thread( + select_info = await asyncio.to_thread( client.select_folder, folder, readonly=not account.get("mark_as_read", False), ) + uidvalidity = select_info.get(b"UIDVALIDITY") if select_info else None + # Check IDLE support if not client.has_capability("IDLE"): logger.warning( @@ -520,21 +530,57 @@ async def watch_folder( account["id"], folder, ) - await _poll_folder( - client, account, folder, webhook_url, api_key, stop_event - ) + # Mutable cursor state — _poll_folder updates it so reconnects + # don't re-baseline. + cursor = { + "last_seen_uid": last_seen_uid, + "last_uidvalidity": last_uidvalidity, + } + try: + await _poll_folder( + client, + account, + folder, + webhook_url, + api_key, + stop_event, + cursor, + ) + finally: + last_seen_uid = cursor["last_seen_uid"] + last_uidvalidity = cursor["last_uidvalidity"] + # _poll_folder only returns when stop_event is set; on errors it + # raises and we fall through to the outer reconnect path. return - mailbox_uids = await asyncio.to_thread(client.search, ["ALL"]) - last_seen_uid = mailbox_uids[-1] if mailbox_uids else 0 - logger.info( - "[%s/%s] Watching for new emails via IDLE from UID %d (%d existing message(s), mark_as_read=%s)", - account["id"], - folder, - last_seen_uid, - len(mailbox_uids), - account.get("mark_as_read", False), - ) + if last_seen_uid is None or uidvalidity != last_uidvalidity: + if last_uidvalidity is not None and uidvalidity != last_uidvalidity: + logger.warning( + "[%s/%s] UIDVALIDITY changed (%s → %s) — resetting cursor", + account["id"], + folder, + last_uidvalidity, + uidvalidity, + ) + mailbox_uids = await asyncio.to_thread(client.search, ["ALL"]) + last_seen_uid = mailbox_uids[-1] if mailbox_uids else 0 + last_uidvalidity = uidvalidity + logger.info( + "[%s/%s] Watching for new emails via IDLE from UID %d (%d existing message(s), mark_as_read=%s)", + account["id"], + folder, + last_seen_uid, + len(mailbox_uids), + account.get("mark_as_read", False), + ) + else: + logger.info( + "[%s/%s] Resumed IDLE watch from UID %d (mark_as_read=%s)", + account["id"], + folder, + last_seen_uid, + account.get("mark_as_read", False), + ) backoff = 5 while not stop_event.is_set(): @@ -611,20 +657,35 @@ async def _poll_folder( webhook_url: str, api_key: str, stop_event: asyncio.Event, + cursor: dict, ): - """Fallback polling for servers without IDLE support. Checks every 60 seconds.""" - mailbox_uids = await asyncio.to_thread(client.search, ["ALL"]) - last_seen_uid = mailbox_uids[-1] if mailbox_uids else 0 - logger.info( - "[%s/%s] Polling for new emails every 60s from UID %d (%d existing message(s), mark_as_read=%s)", - account["id"], - folder, - last_seen_uid, - len(mailbox_uids), - account.get("mark_as_read", False), - ) + """Fallback polling for servers without IDLE support. Checks every 60 seconds. + + `cursor` is a mutable dict with keys 'last_seen_uid' and 'last_uidvalidity' + shared with watch_folder so cursor state survives reconnects. + """ + if cursor.get("last_seen_uid") is None: + mailbox_uids = await asyncio.to_thread(client.search, ["ALL"]) + cursor["last_seen_uid"] = mailbox_uids[-1] if mailbox_uids else 0 + logger.info( + "[%s/%s] Polling for new emails every 60s from UID %d (%d existing message(s), mark_as_read=%s)", + account["id"], + folder, + cursor["last_seen_uid"], + len(mailbox_uids), + account.get("mark_as_read", False), + ) + else: + logger.info( + "[%s/%s] Resumed polling from UID %d (mark_as_read=%s)", + account["id"], + folder, + cursor["last_seen_uid"], + account.get("mark_as_read", False), + ) while not stop_event.is_set(): try: + last_seen_uid = cursor["last_seen_uid"] mailbox_uids = await asyncio.to_thread(client.search, ["ALL"]) new_uids = [uid for uid in mailbox_uids if uid > last_seen_uid] if new_uids: @@ -639,12 +700,12 @@ async def _poll_folder( await _forward_uid_batch( client, account, folder, webhook_url, api_key, new_uids ) - last_seen_uid = new_uids[-1] + cursor["last_seen_uid"] = new_uids[-1] logger.debug( "[%s/%s] Advanced UID cursor to %d", account["id"], folder, - last_seen_uid, + cursor["last_seen_uid"], ) else: logger.debug(