From 59e6c32d79c7dae1fddc89459fcddff6b9d2d1a6 Mon Sep 17 00:00:00 2001 From: Oleg Date: Sun, 26 Jul 2026 13:11:21 +0000 Subject: [PATCH] Fix IMAP idle loop: react to 'exists' event, not idle() resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client.idle() resolves on re-idle/connection events, not per new message — new mail is signaled via the 'exists' event while IDLE is active. The previous loop awaited idle() expecting it to return per message, so new mail was only ever caught on the very first pass. Also made unseen-message processing resilient to a message vanishing between search and fetch (handle one UID at a time, each in its own try/catch) and added logging to make the pipeline observable. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH --- src/lib/mail/imap.ts | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/lib/mail/imap.ts b/src/lib/mail/imap.ts index 4e9d638..a5783d9 100644 --- a/src/lib/mail/imap.ts +++ b/src/lib/mail/imap.ts @@ -38,6 +38,7 @@ function extractBody(parsed: ParsedMail): string { async function handleRawMessage(source: Buffer): Promise { const parsed = await simpleParser(source); const from = parsed.from?.value[0]; + console.log(`[mail] parsed message from=${from?.address ?? "(none)"} subject=${JSON.stringify(parsed.subject)}`); if (!from?.address) return; // no return address — nothing to reply to, skip const referencedIds = [ @@ -59,13 +60,28 @@ async function processUnseenMessages(client: ImapFlow): Promise { const lock = await client.getMailboxLock("INBOX"); try { const uids = await client.search({ seen: false }, { uid: true }); - if (!uids || uids.length === 0) return; + if (!uids || uids.length === 0) { + console.log("[mail] unseen check: 0 messages"); + return; + } + console.log(`[mail] unseen check: ${uids.length} message(s)`); - for await (const message of client.fetch(uids, { source: true, uid: true })) { - if (message.source) { - await handleRawMessage(message.source); + // One at a time, each in its own try/catch — a message that vanishes + // between search and fetch (seen once with a real mail server) must not + // abort processing of the rest of the batch. + for (const uid of uids) { + try { + const message = await client.fetchOne(uid, { source: true }, { uid: true }); + if (message && message.source) { + await handleRawMessage(message.source); + console.log(`[mail] processed message uid=${uid}`); + } else { + console.warn(`[mail] message uid=${uid} had no source (already gone?)`); + } + await client.messageFlagsAdd(uid, ["\\Seen"], { uid: true }); + } catch (err) { + console.error(`[mail] failed to process message uid=${uid}:`, err); } - await client.messageFlagsAdd(message.uid, ["\\Seen"], { uid: true }); } } finally { lock.release(); @@ -75,12 +91,20 @@ async function processUnseenMessages(client: ImapFlow): Promise { async function runIdleLoop(client: ImapFlow): Promise { try { await client.mailboxOpen("INBOX"); + console.log("[mail] IDLE listener connected, watching INBOX"); await processUnseenMessages(client); + // New mail is signaled by the 'exists' event while IDLE is active — it's + // independent of when idle() itself resolves (that happens on periodic + // re-idle / connection events, not per message). Handle new mail here; + // use the loop below purely to keep re-entering IDLE mode. + client.on("exists", () => { + console.log("[mail] 'exists' event fired — checking for new mail"); + processUnseenMessages(client).catch((err) => console.error("Error processing new mail:", err)); + }); + while (state.client === client) { await client.idle(); - if (state.client !== client) break; - await processUnseenMessages(client); } } catch (err) { if (state.client === client) {