imapflow
Version:
IMAP Client for Node
245 lines (217 loc) • 9.79 kB
JavaScript
;
const NOOP_INTERVAL = 2 * 60 * 1000;
/**
* Runs a single IDLE session on the connection.
*
* @param {Object} connection - IMAP connection instance
* @returns {Promise<void|boolean>} 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 = [];
try {
connection.idling = true;
// 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?.lockId,
path: connection.mailbox && connection.mailbox.path
});
connection.write('DONE');
doneSent = true;
connection.idling = false;
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 = () => {
let handler = new Promise((resolve, reject) => {
preCheckWaitQueue.push({ resolve, reject });
});
connection.log.trace({
msg: 'Requesting IDLE break',
lockId: connection.currentLock?.lockId,
path: connection.mailbox && connection.mailbox.path,
queued: preCheckWaitQueue.length,
doneRequested,
canEnd,
doneSent
});
preCheck().catch(err => connection.log.warn({ err, cid: connection.id }));
return handler;
};
// Register preCheck on the connection so other code (e.g., getMailboxLock) can break IDLE
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 () => {
connection.log.debug({ msg: `Initiated IDLE, waiting for server input`, lockId: connection.currentLock?.lockId, doneRequested });
canEnd = true;
if (doneRequested) {
try {
await preCheck();
} catch (err) {
connection.log.warn({ err, cid: connection.id });
}
}
},
onSend: () => {}
});
// Clean up: unset preCheck and resolve any remaining waiters before processing the response.
// Usually preCheck is already cleared by the DONE handler, but this handles edge cases.
if (typeof connection.preCheck === 'function' && connection.preCheck === connectionPreCheck) {
connection.log.trace({
msg: 'Clearing pre-check function',
lockId: connection.currentLock?.lockId,
path: connection.mailbox && connection.mailbox.path,
queued: preCheckWaitQueue.length,
doneRequested,
canEnd,
doneSent
});
connection.preCheck = false;
while (preCheckWaitQueue.length) {
let { resolve } = preCheckWaitQueue.shift();
resolve();
}
}
response.next();
return;
} catch (err) {
connection.preCheck = false;
connection.idling = false;
connection.log.warn({ err, cid: connection.id });
while (preCheckWaitQueue.length) {
let { reject } = preCheckWaitQueue.shift();
reject(err);
}
return false;
}
}
/**
* Listens for changes in the selected mailbox using IDLE or NOOP polling fallback.
*
* @param {Object} connection - IMAP connection instance
* @param {number} [maxIdleTime] - Maximum time in milliseconds to stay in IDLE before restarting
* @returns {Promise<void|boolean|undefined>} Void on success, false on failure, or undefined if not in SELECTED state
*/
module.exports = async (connection, maxIdleTime) => {
if (connection.state !== connection.states.SELECTED) {
// nothing to do here
return;
}
// If server supports IDLE (RFC 2177), use it for real-time push notifications.
// Otherwise, fall back to periodic polling with NOOP/STATUS/SELECT.
if (connection.capabilities.has('IDLE')) {
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).
let runIdleLoop = async () => {
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 => connection.log.warn({ err, cid: connection.id }));
}
}
}, maxIdleTime);
}
let resp = await runIdle(connection);
clearTimeout(idleTimer);
if (stillIdling) {
stillIdling = false;
return runIdleLoop();
}
return resp;
};
return runIdleLoop();
}
// Fallback for servers without IDLE support: poll at regular intervals using
// NOOP (default), STATUS, or SELECT depending on missingIdleCommand config.
let idleTimer;
return new Promise(resolve => {
if (!connection.currentSelectCommand) {
return resolve();
}
// Set up preCheck so other commands can break the polling loop
connection.preCheck = async () => {
connection.preCheck = false; // unset itself
clearTimeout(idleTimer);
connection.log.debug({ src: 'c', msg: `breaking NOOP loop` });
connection.idling = false;
resolve();
};
let selectCommand = connection.currentSelectCommand;
// Run one polling check. The method used depends on configuration:
// SELECT re-selects the mailbox (may detect changes), STATUS queries mailbox counters,
// NOOP is the simplest but relies on server pushing untagged responses.
let idleCheck = async () => {
let response;
switch (connection.missingIdleCommand) {
case 'SELECT':
// FIXME: somehow a loop occurs after some time of idling with SELECT
connection.log.debug({ src: 'c', msg: `Running SELECT to detect changes in folder` });
response = await connection.exec(selectCommand.command, selectCommand.arguments);
break;
case 'STATUS':
{
let statusArgs = [
selectCommand.arguments[0],
['MESSAGES', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN'].map(key => ({ type: 'ATOM', value: key }))
];
connection.log.debug({ src: 'c', msg: `Running STATUS to detect changes in folder` });
response = await connection.exec('STATUS', statusArgs);
}
break;
case 'NOOP':
default:
response = await connection.exec('NOOP', false, { comment: 'IDLE not supported' });
break;
}
response.next();
};
let noopInterval = maxIdleTime ? Math.min(NOOP_INTERVAL, maxIdleTime) : NOOP_INTERVAL;
let runLoop = () => {
idleCheck()
.then(() => {
clearTimeout(idleTimer);
idleTimer = setTimeout(runLoop, noopInterval);
})
.catch(err => {
clearTimeout(idleTimer);
connection.preCheck = false;
connection.log.warn({ err, cid: connection.id });
resolve();
});
};
connection.log.debug({ src: 'c', msg: `initiated NOOP loop` });
connection.idling = true;
runLoop();
});
};