From 95c3739291b871af98a0de31c1ffe5c258b8058d Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Sun, 2 Aug 2026 12:24:15 -0400 Subject: [PATCH] Add src/watcher.py with IMAP IDLE FETCH handling fix --- src/watcher.py | 111 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/watcher.py diff --git a/src/watcher.py b/src/watcher.py new file mode 100644 index 0000000..c5ceb5f --- /dev/null +++ b/src/watcher.py @@ -0,0 +1,111 @@ +async def watch_folder( + account: dict, + folder: str, + webhook_url: str, + api_key: str, + stop_event: asyncio.Event, +): + backoff = 5 + max_backoff = 60 + + while not stop_event.is_set(): + client = None + try: + client = await asyncio.to_thread(get_imap_client, account) + await asyncio.to_thread( + client.select_folder, + folder, + readonly=not account.get("mark_as_read", False), + ) + + # Check IDLE support + if not client.has_capability("IDLE"): + logger.warning( + "[%s/%s] Server does not support IDLE, falling back to polling", + account["id"], + folder, + ) + await _poll_folder( + client, account, folder, webhook_url, api_key, stop_event + ) + return + + # Record existing unseen UIDs so we only forward truly new ones + existing_unseen = set(await asyncio.to_thread(client.search, ["UNSEEN"])) + logger.info( + "[%s/%s] Watching for new emails via IDLE (%d existing unseen skipped)", + account["id"], + folder, + len(existing_unseen), + ) + backoff = 5 + + while not stop_event.is_set(): + await asyncio.to_thread(client.idle) + try: + responses = await asyncio.to_thread(client.idle_check, 120) + except Exception: + try: + await asyncio.to_thread(client.idle_done) + except Exception: + pass + break + await asyncio.to_thread(client.idle_done) + + logger.debug( + "[%s/%s] IDLE responses: %s", account["id"], folder, responses + ) + + uids = await asyncio.to_thread(client.search, ["UNSEEN"]) + # Only forward emails that arrived after we started watching + new_uids = [u for u in uids if u not in existing_unseen] + if not new_uids: + continue + + # Fetch the full RFC822 payload for any newly‑seen messages. + # Some IMAP servers may emit malformed FETCH responses that cause + # ``client.fetch`` to raise a ``ValueError``. Previously this would + # crash the watcher loop. We now catch that error, log a warning, + # and skip the offending UIDs so the watcher can continue. + try: + raw_messages = await asyncio.to_thread( + client.fetch, new_uids, ["RFC822"] + ) + except ValueError as e: + logger.warning( + "[%s/%s] IMAP FETCH error for UIDs %s: %s — skipping", + account["id"], + folder, + new_uids, + e, + ) + raw_messages = {} + for uid, data in raw_messages.items(): + raw = data.get(b"RFC822", b"") + if not raw: + continue + email_data = parse_email_message(raw) + await forward_to_poke(email_data, account, webhook_url, api_key) + + if account.get("mark_as_read", False): + await asyncio.to_thread(client.set_flags, new_uids, [b"\\Seen"]) + existing_unseen.update(new_uids) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error( + "[%s/%s] Watcher error: %s (reconnecting in %ds)", + account["id"], + folder, + e, + backoff, + ) + await asyncio.sleep(backoff) + backoff = min(backoff * 2, max_backoff) + finally: + if client: + try: + await asyncio.to_thread(client.logout) + except Exception: + pass