Fix IMAP idle loop: react to 'exists' event, not idle() resolution

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-07-26 13:11:21 +00:00
1 parent 7e62ed739b
commit 59e6c32d79
1 file changed
+30 -6
+30 -6
View File
@@ -38,6 +38,7 @@ function extractBody(parsed: ParsedMail): string {
async function handleRawMessage(source: Buffer): Promise<void> {
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<void> {
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) {
// 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<void> {
async function runIdleLoop(client: ImapFlow): Promise<void> {
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) {