import { guardedPromise, hasCapability, isServerRefusal, logConnectionError, restampConnectionError, unrefTimer, clearTimer, getSelectedMailbox } from '../tools.js'; import { createImapError } from '../errors.js'; const NOOP_INTERVAL = 2 * 60 * 1000; /** * Marks the connection as idling on behalf of one session and returns a release function. * * Both IDLE modes use this so ownership of the shared `idling` flag is explicit: a session that * finishes late (a poll that completes after cancellation, an IDLE that unwinds after a restart) * cannot clear the flag of the session that has since taken over. * * @param connection - IMAP connection instance * @returns Release function, safe to call more than once */ function claimIdling(connection) { let token = {}; connection._idleSession = token; connection.idling = true; return () => { if (connection._idleSession === token) { connection._idleSession = null; connection.idling = false; } }; } /** * Runs a single IDLE session on the connection. * * @param connection - IMAP connection instance * @returns Void on success, false on failure */ async function runIdle(connection) { let response; // Queue of promises waiting for IDLE to break. When another command needs to run, // it calls connection.preCheck() which queues a promise here and sends DONE to break IDLE. let preCheckWaitQueue = []; // The preCheck function this session owns. Only this session may clear it from the // connection, so a newer IDLE session that already installed its own is left alone. let ownPreCheck = null; let releaseIdling = claimIdling(connection); // The deadline for the server's "+" continuation, see the onSend handler below let continuationTimer = null; let clearContinuationTimer = () => { clearTimer(continuationTimer); continuationTimer = null; }; try { // State flags for the IDLE lifecycle: // - doneRequested: someone wants to break IDLE (e.g., to run another command) // - doneSent: we've already sent the DONE command to server // - canEnd: server has acknowledged IDLE with "+" continuation, so DONE can be sent let doneRequested = false; let doneSent = false; let canEnd = false; // preCheck sends DONE to break out of IDLE. Called when another command // needs to run on this connection (e.g., a FETCH or STORE from user code). let preCheck = async () => { doneRequested = true; if (canEnd && !doneSent) { connection.log.debug({ src: 'c', msg: `DONE`, comment: `breaking IDLE`, lockId: connection.currentLock ? connection.currentLock.lockId : undefined, path: connection.mailbox && connection.mailbox.path, cid: connection.id }); // Marked before the write: write() closes the connection when the transport is // already gone, and close() breaks IDLE through this very function, which // would otherwise write DONE again from inside itself doneSent = true; connection.write('DONE'); releaseIdling(); if (connection.preCheck === ownPreCheck) { connection.preCheck = false; // unset itself } while (preCheckWaitQueue.length) { let { resolve } = preCheckWaitQueue.shift(); resolve(); } } }; // Public interface for breaking IDLE. Returns a promise that resolves when // IDLE is actually broken and the connection is free for other commands. let connectionPreCheck = () => { // Guarded: the catch block below rejects every queued waiter synchronously while // close() tears the connection down. See guardedPromise(). let handler = guardedPromise((resolve, reject) => { preCheckWaitQueue.push({ resolve, reject }); }); connection.log.trace({ msg: 'Requesting IDLE break', lockId: connection.currentLock ? connection.currentLock.lockId : undefined, path: connection.mailbox && connection.mailbox.path, queued: preCheckWaitQueue.length, doneRequested, canEnd, doneSent, cid: connection.id }); preCheck().catch((err) => logConnectionError(connection, 'Failed to break IDLE', err)); return handler; }; // Register preCheck on the connection so other code (e.g., getMailboxLock) can break IDLE ownPreCheck = connectionPreCheck; connection.preCheck = connectionPreCheck; response = await connection.exec('IDLE', false, { // Server responds with "+" continuation to acknowledge IDLE mode. // After this, the server will push untagged responses for mailbox changes. // We can now safely send DONE if a break was already requested. onPlusTag: async () => { clearContinuationTimer(); connection.log.debug({ msg: `Initiated IDLE, waiting for server input`, lockId: connection.currentLock ? connection.currentLock.lockId : undefined, doneRequested, cid: connection.id }); canEnd = true; if (doneRequested) { try { await preCheck(); } catch (err) { logConnectionError(connection, 'Failed to break IDLE', err); } } }, // The server has socketTimeout from the moment IDLE is on the wire to acknowledge it // with the "+". The socket watchdog only covers a server that goes quiet: one that // keeps sending untagged lines without ever answering "+" kept the IDLE in flight for // good, and every later command queued behind it. DONE can not be sent before the // "+" (RFC 2177 section 3), so there is no way to break such an IDLE and fall back // to polling; the connection is failed the way the watchdog fails a dead peer. onSend: () => { continuationTimer = unrefTimer(setTimeout(() => { continuationTimer = null; if (canEnd || connection.isClosed) { return; } connection.log.error({ msg: 'Server did not acknowledge IDLE, closing the connection', timeout: connection.socketTimeout, cid: connection.id }); connection.emitError(createImapError('IDLE was not acknowledged within the socket timeout', 'ETIMEOUT', { command: 'IDLE', details: { socketTimeout: connection.socketTimeout } })); }, connection.socketTimeout)); } }); response.next(); return; } catch (err) { logConnectionError(connection, 'IDLE session failed', err); // A tagged NO or BAD only means the server did not run IDLE; the connection is still // usable, so the waiters are released by the finally block below and their own commands // run. Anything else (close, lost socket, parser failure) fails the waiters too. Only a // refusal turns IDLE off for the session, a throttled IDLE is tried again later. let answeredByServer = err.responseStatus === 'NO' || err.responseStatus === 'BAD'; if (isServerRefusal(err)) { connection.skipIdle = true; } if (preCheckWaitQueue.length && !answeredByServer) { // One error for the whole queue: every waiter failed at the same site, for the same // reason. Built inside the guard so a teardown with nothing queued - the common case - // does not pay for an Error and its stack capture. let waiterError = restampConnectionError(err, { rejectedFrom: 'preCheckWaiter' }); while (preCheckWaitQueue.length) { let { reject } = preCheckWaitQueue.shift(); reject(waiterError); } } return false; } finally { // Single ownership cleanup for every outcome: explicit break, tagged completion // (including a server-terminated IDLE, where preCheck never ran), rejected command, // parser failure and connection close. `idling` drives socket-timeout handling, so it // must always describe reality rather than being left over from a previous state. clearContinuationTimer(); releaseIdling(); if (connection.preCheck === ownPreCheck) { connection.preCheck = false; } while (preCheckWaitQueue.length) { let { resolve } = preCheckWaitQueue.shift(); resolve(); } } } /** * Runs one fallback poll. The real SELECT and STATUS commands are reused instead of replaying the * saved wire arguments, so a poll applies exactly the same mailbox state transitions, events and * failure handling as a caller-issued command. They go through connection.runInternal() rather * than connection.run(), because run() awaits preCheck() - and the preCheck it would await is the * one this very polling session installed, so the session would cancel itself. * * @param connection - IMAP connection instance * @param session - Polling session state */ async function pollOnce(connection, session) { let path = connection.mailbox && connection.mailbox.path; switch (connection.missingIdleCommand) { case 'SELECT': connection.log.debug({ msg: `Running SELECT to detect changes in folder`, cid: connection.id }); await connection.runInternal('SELECT', path, { readOnly: session.selectCommand.command === 'EXAMINE' }); break; case 'STATUS': { connection.log.debug({ msg: `Running STATUS to detect changes in folder`, cid: connection.id }); // HIGHESTMODSEQ is filtered out again unless the server advertises CONDSTORE, so a // CONDSTORE session keeps mailbox.highestModseq current without asking a plain // server for an item it does not know. let polled = connection.mailbox; let status = await connection.runInternal('STATUS', path, { messages: true, uidNext: true, uidValidity: true, unseen: true, highestModseq: true }); if (!status) { throw createImapError('STATUS poll failed', 'PollFailed'); } // STATUS can only tell that the UIDs changed, the selected view is stale then: select // the mailbox again, which reports it as closed and opened anew with the new state if (polled && connection.mailbox === polled && status.uidValidity && polled.uidValidity && status.uidValidity !== polled.uidValidity) { connection.log.info({ msg: 'UIDVALIDITY changed, selecting the mailbox again', path, cid: connection.id }); await connection.runInternal('SELECT', path, { readOnly: session.selectCommand.command === 'EXAMINE' }); } break; } case 'NOOP': default: { let response = await connection.exec('NOOP', false, { comment: 'IDLE not supported' }); response.next(); break; } } } /** * Polls the selected mailbox at a fixed interval, for servers without IDLE support. * * The loop is one explicitly identified session: cancellation is idempotent, is checked before * a poll starts and again before the next timer is scheduled, and a poll that completes after * cancellation can neither run again nor take ownership away from a newer IDLE session. * * @param connection - IMAP connection instance * @param maxIdleTime - Upper bound for the polling interval */ async function runPollingFallback(connection, maxIdleTime) { if (!connection.currentSelectCommand) { return; } let session = { cancelled: false, timer: null, preCheck: null, selectCommand: connection.currentSelectCommand }; let interval = maxIdleTime ? Math.min(NOOP_INTERVAL, maxIdleTime) : NOOP_INTERVAL; let releaseIdling = claimIdling(connection); try { await new Promise(resolve => { // Idempotent cancellation. Never keyed off connection.preCheck, because a newer IDLE // session may already own that property by the time an old poll settles. const cancel = () => { if (session.cancelled) { return; } session.cancelled = true; clearTimer(session.timer); session.timer = null; resolve(); }; session.preCheck = async () => { connection.log.debug({ msg: `Breaking NOOP loop`, cid: connection.id }); cancel(); }; connection.preCheck = session.preCheck; const runPoll = () => { if (session.cancelled) { return; } // The transport or the mailbox may be gone by the time the timer fires if (!connection.socket || connection.socket.destroyed || !getSelectedMailbox(connection)) { return cancel(); } pollOnce(connection, session) .then(() => { // Stamped only after a poll actually completed: a failed poll must not // satisfy the resumed schedule below, or the next session would defer // its first poll a full interval past an attempt that checked nothing. connection._lastPollAt = Date.now(); // Cancellation is re-checked here: the session may have been broken while // this poll was in flight, and an orphaned poller must not schedule again. if (session.cancelled) { return; } scheduleNextPoll(interval); }) .catch((err) => { logConnectionError(connection, 'Failed to poll for mailbox changes', err); cancel(); }); }; function scheduleNextPoll(delay) { session.timer = setTimeout(runPoll, delay); // Background polling must not keep the process alive unrefTimer(session.timer); } connection.log.debug({ msg: `Initiated NOOP loop`, cid: connection.id }); // Every auto-IDLE restart begins a fresh polling session, so an unconditional first // poll would tie the poll rate to how often the caller runs commands rather than to // `interval`: with a short autoIdleDelay, a command every few seconds turns into a // poll every few seconds. The last poll timestamp lives on the connection, so a new // session resumes the previous one's schedule instead of restarting it. // Clamped at zero because a backward wall-clock step (NTP, VM resume) leaves the // stamp in the future; however large the jump, the next poll must never be more // than one full interval away. let sinceLastPoll = Math.max(0, Date.now() - (connection._lastPollAt || 0)); if (sinceLastPoll >= interval) { runPoll(); } else { scheduleNextPoll(interval - sinceLastPoll); } }); } finally { session.cancelled = true; clearTimer(session.timer); session.timer = null; releaseIdling(); if (connection.preCheck === session.preCheck) { connection.preCheck = false; } } } /** * Listens for changes in the selected mailbox using IDLE or NOOP polling fallback. * * @param connection - IMAP connection instance * @param maxIdleTime - Maximum time in milliseconds to stay in IDLE before restarting * @returns Void on success, false on failure, or undefined if not in SELECTED state */ export default async function idle(connection, maxIdleTime) { if (connection.state !== connection.states.SELECTED) { // nothing to do here return; } // If server supports IDLE (RFC 2177, folded into base IMAP4rev2), use it for // real-time push notifications. Otherwise, fall back to periodic polling with // NOOP/STATUS/SELECT. if (hasCapability(connection, 'IDLE') && !connection.skipIdle) { let idleTimer; let stillIdling = false; // IDLE loop: runs IDLE, and if maxIdleTime is reached, breaks and restarts to keep the // connection alive (some servers drop long-running IDLEs). Iterative rather than // recursive, so a long-lived idling connection does not retain one pending frame per // restart. for (;;) { if (maxIdleTime) { idleTimer = setTimeout(() => { if (connection.idling) { if (typeof connection.preCheck === 'function') { stillIdling = true; // request IDLE break if IDLE has been running for allowed time connection.log.trace({ msg: 'Max allowed IDLE time reached', cid: connection.id }); connection.preCheck().catch((err) => logConnectionError(connection, 'Failed to break IDLE for restart', err)); } } }, maxIdleTime); // Background IDLE restart timer must not keep the process alive unrefTimer(idleTimer); } let resp = await runIdle(connection); clearTimeout(idleTimer); // A restart only makes sense with nothing queued behind the break (a CLOSE, say; run() // re-arms auto-IDLE once that command is done) and the mailbox still selected on a // usable connection const canRestart = stillIdling && !connection.requestQueue.length && !!getSelectedMailbox(connection) && connection.usable; if (!canRestart) { return resp; } stillIdling = false; } } // Fallback for servers without IDLE support, or that refused it: poll at regular intervals using // NOOP (default), STATUS, or SELECT depending on missingIdleCommand config. return runPollingFallback(connection, maxIdleTime); }