/** * @module imapflow */ import tls from 'node:tls'; import net from 'node:net'; import zlib from 'node:zlib'; import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; import { createConnectionLogger } from './logger.js'; import * as packageInfo from './package-info.js'; import { ImapStream } from './handler/imap-stream.js'; import { parser, compiler } from './handler/imap-handler.js'; import { proxyConnection, detachEarlyErrorHandler } from './proxy-connection.js'; import { ConnectionDeadline } from './connection-deadline.js'; import { downloadMessage, downloadMessageParts } from './download.js'; import { AuthenticationFailure, createImapError } from './errors.js'; import imapCommands from './imap-commands.js'; import { comparePaths, updateCapabilities, getFolderTree, formatMessageResponse, mergeFetchRows, packMessageRange, normalizePath, iterateRange, countRange, getColorFlags, hasCapability, isRev2Active, logConnectionError, unrefTimer, clearTimer, parseUintValue, getStringList, getTextValues, emitSafe, buildConnectionError, guardedPromise, guardedReject, isIpLiteral, MAX_UINT32_DIGITS } from './tools.js'; export { AuthenticationFailure, ImapFlowErrorCode } from './errors.js'; export { createSocketsTransport } from './sockets-transport.js'; export { createStreamsTransport } from './streams-transport.js'; const GREETING_TIMEOUT = 16 * 1000; // Status responses that can carry an ALERT response code const ALERT_RESPONSES = new Set(['OK', 'NO', 'BAD', 'BYE', 'PREAUTH']); const UPGRADE_TIMEOUT = 10 * 1000; const SOCKET_TIMEOUT = 5 * 60 * 1000; // Ceiling for any throttle back-off wait. Both the connection-level back-off and the per-command // retries derive their delay from server-supplied hints, which are unbounded. const MAX_THROTTLE_DELAY = 5 * 60 * 1000; // Default threshold for warning that a mailbox lock has been held for a long // time. Intended to catch forgotten release() calls, not legitimate long ops // (e.g. fetching hundreds of thousands of messages). Configurable via the // ImapFlow constructor option `maxLockHoldTime`. Set to 0 or false to disable. const HELD_LOCK_WARN_MS = 30 * 60 * 1000; // How long the connection has to stay inactive before auto-IDLE starts. Long enough that a caller // running a sequence of commands is not interrupted by an IDLE it immediately has to break. // Configurable via the ImapFlow constructor option `autoIdleDelay`. const AUTO_IDLE_DELAY = 15 * 1000; // Headroom kept between the auto-IDLE delay and the socket inactivity watchdog, so IDLE reaches // the wire before the watchdog can fire. See normalizeAutoIdleDelay(). const AUTO_IDLE_SOCKET_MARGIN = 1000; // Commands whose client frames carry credentials; the raw traffic log withholds frame content // while one of these is in flight. See the logRaw branch in write(). const RAW_SENSITIVE_COMMANDS = new Set(['LOGIN', 'AUTHENTICATE']); // Stand-in payload for a withheld raw client frame. Fixed width, so the entry says nothing // about the length of what it replaced. const RAW_HIDDEN_PLACEHOLDER = Buffer.from('(* value hidden *)\r\n').toString('base64'); // From this size up, a Buffer written as the last part of a command gets its CRLF as a socket // write of its own instead of being copied to append it (see write()) const SEPARATE_LINE_BREAK_SIZE = 64 * 1024; const CRLF = Buffer.from('\r\n'); // Servers, matched by the name in their ID response, that advertise IMAP4rev2 next to // IMAP4rev1 and accept ENABLE IMAP4rev2, but misbehave once it is enabled. ENABLE cannot // be undone (RFC 5161), so these are kept in IMAP4rev1 mode from the start. Strato's // RZimapd (7.1.12, 2026-09) answers every SEARCH in a rev2 session with an ESEARCH // response that omits ALL, which reads as "no matches" for any query. Names are lowercase const BROKEN_REV2_SERVERS = new Set(['rzimapd']); // Whether any attribute of a command is marked as a secret. Recurses into nested lists because // the command compiler honors `sensitive` at any depth, and the two must agree on what counts. function hasSensitiveAttribute(attributes) { return [] .concat(attributes || []) .some(node => (Array.isArray(node) ? hasSensitiveAttribute(node) : !!node && typeof node === 'object' && !Buffer.isBuffer(node) && !!node.sensitive)); } // How deep flattenLoggedError() follows a chain of errors. Bounded because the chain comes from // whatever failed, not from this library: a cause chain can be arbitrarily long, and the cycle // check below only catches errors that repeat. const MAX_ERROR_FLATTEN_DEPTH = 4; // Recognizes an Error without instanceof, which fails for an error that crossed a realm boundary // (worker thread, vm context) even though it serializes exactly the same way. function isErrorLike(value) { return (value instanceof Error || (!!value && typeof value === 'object' && typeof value.message === 'string' && typeof value.stack === 'string')); } // An Error carries `message` and `stack` on its prototype rather than as own enumerable // properties, so JSON.stringify() renders one as `{}` and both logger fallback paths (the console // fallback and emitLogs) would drop everything identifying it. Flattening happens here for both, // so their shapes cannot drift apart. // // Nested errors are flattened too, because the top level is often not where the answer is: this // library attaches the underlying failure as an enumerable `_err` (proxy setup, response // processing, normalized connection deadlines), and Node reports a multi-address connect failure // as an AggregateError whose members hold the per-address causes. function flattenLoggedError(value, depth = 0, seen = new Set()) { if (depth >= MAX_ERROR_FLATTEN_DEPTH) { return isErrorLike(value) ? value.message : value; } if (Array.isArray(value)) { return value.map(entry => flattenLoggedError(entry, depth + 1, seen)); } if (!isErrorLike(value)) { // Anything else is left alone: exploding a Buffer would produce one key per byte, and a // Date would become a pair of undefined fields. return value; } // A repeat renders as its message alone, so a chain that loops back does not restate a full // stack for every level down to the depth cap if (seen.has(value)) { return value.message; } seen.add(value); let flatErr = { message: value.message, stack: value.stack }; // `cause` (passed through the Error options argument) and the AggregateError members are own // properties but not enumerable, so Object.keys does not list them for (let key of new Set([...Object.keys(value), 'cause', 'errors'])) { if (key in value) { flatErr[key] = flattenLoggedError(value[key], depth + 1, seen); } } return flatErr; } // The largest delay setTimeout can honor (2^31 - 1 ms). Anything above fires after 1 ms instead, // so the auto-IDLE delay cap has to stay inside this range even when socketTimeout is not. const MAX_TIMER_DELAY = 2 ** 31 - 1; const stateValues = { NOT_AUTHENTICATED: 0x01, AUTHENTICATED: 0x02, SELECTED: 0x03, LOGOUT: 0x04 }; const states = stateValues; /** * Normalizes the configured auto-IDLE delay into a value `setTimeout` can honor. Anything Node * would silently turn into a 1ms timer - NaN, a negative number, a value above the 32-bit range - * falls back to the default instead, because a 1ms delay means an IDLE/DONE round trip around * every single command. The delay is also capped below `socketTimeout`, see AUTO_IDLE_SOCKET_MARGIN. * * @param value - The configured `autoIdleDelay` option. * @param socketTimeout - The normalized socket inactivity timeout. * @param log - Logger, used to report a value that could not be used as given. * @param cid - Connection id for the log entry. * @returns Delay in milliseconds. */ const normalizeAutoIdleDelay = (value, socketTimeout, log, cid) => { const maxDelay = Math.max(0, Math.min(socketTimeout, MAX_TIMER_DELAY) - AUTO_IDLE_SOCKET_MARGIN); const configured = value !== undefined && value !== null; // Numeric strings are accepted, because configuration usually arrives from an environment // variable or a JSON file. Booleans and blank strings are not: Number() would read them as 0, // i.e. "IDLE around every command", the opposite of the "off" they suggest. let delay = typeof value === 'number' || (typeof value === 'string' && value.trim()) ? Number(value) : NaN; let reason = null; if (!Number.isFinite(delay) || delay < 0) { reason = 'not a non-negative finite number'; delay = AUTO_IDLE_DELAY; } if (delay > maxDelay) { // An invalid value keeps its own reason: the cap then applies to the fallback default, // not to anything the caller asked for. reason = reason || `above socketTimeout (${socketTimeout} ms)`; delay = maxDelay; } // Only an explicitly configured value is worth warning about. Capping the default because the // caller picked a short socketTimeout is expected behavior, not a misconfiguration. if (configured && reason) { log.warn({ msg: 'Adjusted unusable autoIdleDelay option', requested: value, autoIdleDelay: delay, reason, cid }); } return Math.floor(delay); }; /** * IMAP client class for accessing IMAP mailboxes */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class ImapFlow extends EventEmitter { /** * Current module version as a static class property */ static { this.version = packageInfo.version; } /** * The mailboxes a `verifyOnly` connection with `includeMailboxes` listed before it logged out, * `undefined` otherwise * * @example * let client = new ImapFlow({ ..., verifyOnly: true, includeMailboxes: true }); * await client.connect(); * console.log(client.mailboxList.map(mailbox => mailbox.path)); */ get mailboxList() { return this._mailboxList; } /** * Creates a client for one IMAP connection. Nothing is sent before `connect()` is called */ constructor(options) { super({ captureRejections: true }); this.options = options || {}; this.id = this.options.id || this.getRandomId(); this.clientInfo = Object.assign({ name: packageInfo.name, version: packageInfo.version, vendor: 'Postal Systems', 'support-url': 'https://github.com/postalsys/imapflow/issues' }, this.options.clientInfo || {}); // remove diacritics for (let key of Object.keys(this.clientInfo)) { if (typeof this.clientInfo[key] === 'string') { this.clientInfo[key] = this.clientInfo[key].normalize('NFD').replace(/\p{Diacritic}/gu, ''); } } this.serverInfo = null; //updated by ID this.log = this.getLogger(); this.secureConnection = !!this.options.secure; // 993 is IMAPS, 143 is IMAP over cleartext/STARTTLS. The non-secure default used to be 110, // which is POP3 - a client created without an explicit port could never connect. this.port = Number(this.options.port) || (this.secureConnection ? 993 : 143); this.host = this.options.host || 'localhost'; this.servername = this.options.servername ? this.options.servername : !isIpLiteral(this.host) ? this.host : false; if (typeof this.options.secure === 'undefined' && this.port === 993) { // if secure option is not set but port is 993, then default to secure this.secureConnection = true; } // Normalized once so direct TLS, cleartext, proxied and STARTTLS-upgraded transports // cannot end up with different inactivity watchdogs. As documented, 0 (and any other // falsy or invalid value) means "use the default", not "disable". this.socketTimeout = Number(this.options.socketTimeout) || SOCKET_TIMEOUT; this.logRaw = this.options.logRaw; this.streamer = new ImapStream({ logger: this.log, cid: this.id, logRaw: this.logRaw, secureConnection: this.secureConnection, maxLineLength: this.options.maxLineLength, maxLiteralSize: this.options.maxLiteralSize, maxResponseSize: this.options.maxResponseSize }); this.reading = false; this.socket = false; this.writeSocket = false; this._throttleWaits = new Set(); this._upgradeReject = null; this.isClosed = false; this._errorReported = false; this.states = states; this.state = this.states.NOT_AUTHENTICATED; this.lockCounter = 0; this.tagCounter = 0; this.requestTagMap = new Map(); this.requestQueue = []; this.currentRequest = false; this.parkedRelease = null; this._unknownTagCount = 0; this._nextUnknownTagWarn = 1; this.writeBytesCounter = 0; this.commandParts = []; this.rawSensitiveCommand = true; this.capabilities = new Map(); this.authCapabilities = new Map(); this.rawCapabilities = null; this.expectCapabilityUpdate = false; // force CAPABILITY after LOGIN this._starttlsHadTrailingData = false; this.enabled = new Set(); this.usable = false; this.authenticated = false; this.mailbox = false; this.currentSelectCommand = false; this.idling = false; this.context = this.options.context; this.connectedAt = null; this.lastClientWrite = null; this._idleWaiters = []; this.idlingAtClose = false; this.emitLogs = !!this.options.emitLogs; this.lo = 0; this.untaggedHandlers = {}; this.sectionHandlers = {}; this.commands = imapCommands; this.folders = new Map(); this.currentLock = false; this.locks = []; this.idRequested = false; this.maxIdleTime = this.options.maxIdleTime || false; this.autoIdleDelay = normalizeAutoIdleDelay(this.options.autoIdleDelay, this.socketTimeout, this.log, this.id); this._lastPollAt = 0; this._openDownloads = 0; this._recoveryPending = false; this._processingResponse = false; this.missingIdleCommand = (this.options.missingIdleCommand || '').toString().toUpperCase().trim() || 'NOOP'; this.disableBinary = !!this.options.disableBinary; this.idHashAlgorithm = this.options.idHashAlgorithm || 'md5'; this.skipListSubscribedArg = false; this.skipListStatusArgs = false; this.skipListAuxArgs = false; this.skipLsub = false; this.skipIdle = false; this.skipRev2 = !!this.options.disableIMAP4rev2; // Named error handler for proper cleanup. Certain error codes represent // expected socket/network issues (buffer exhaustion, connection reset, broken pipe, // timeout, unreachable host) that just need a silent connection close rather // than emitting an error event to the caller. this._streamerErrorHandler = (err) => { if (['Z_BUF_ERROR', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'EHOSTUNREACH'].includes(err.code)) { this.closeAfter(); return; } this.log.error({ err, cid: this.id }); this.emitError(err); }; this.streamer.on('error', this._streamerErrorHandler); this._connectCalled = false; } /** @internal */ emitError(err) { if (!err) { return; } err._connId = err._connId || this.id; // One error per connection: emitError() closes the connection, and until the deferred // close() runs a second socket, deflate or parser error may still arrive. Emitting it as // well would reach a consumer that removed its listener after the first one, or one that // only awaited connect(), as an unhandled 'error' that throws. if (this._errorReported) { this.log.debug({ msg: 'Suppressed error after the connection already reported one', err, cid: this.id }); this.closeAfter(); return; } // During a STARTTLS handshake the upgrade owns the single error path (its settle() // helper). Route the error there so a streamer-originated failure is surfaced with its // real code (instead of a generic ClosedAfterConnect*) and cannot hang a verifyOnly // connect() waiting on a 'close' that never rejects. Fall back to closing if the upgrade // has no pending rejector. if (this.upgrading) { let reject = this._upgradeReject; this._upgradeReject = null; if (typeof reject === 'function') { // settle() clears the upgrade timer and flags, and closes the connection this._errorReported = true; reject(err); return; } this.upgrading = false; this.closeAfter(); return; } // While the initial connect promise is still pending it owns error reporting: // reject it once instead of emitting a duplicate 'error' event (which would also // throw if the caller has not attached an 'error' listener yet). if (typeof this.initialReject === 'function') { let reject = this.initialReject; this.initialResolve = false; this.initialReject = false; this._errorReported = true; this.closeAfter(); reject(err); return; } this._errorReported = true; this.closeAfter(); this.emit('error', err); } /** @internal */ getRandomId() { let rid = BigInt('0x' + Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(13))).toString('hex')).toString(36); if (rid.length < 20) { rid = '0'.repeat(20 - rid.length) + rid; } if (rid.length > 20) { rid = rid.substring(0, 20); } return rid; } /** @internal */ write(chunk) { if (!this.socket || this.socket.destroyed) { // do not write after connection end or logout throw this.createConnectionError('NoConnection', 'Socket is already closed', { rejectedFrom: 'writeNoSocket' }); } if (this.state === this.states.LOGOUT) { // should not happen throw this.createConnectionError('StateLogout', 'Can not send data after logged out', { rejectedFrom: 'writeAfterLogout' }); } if (this.writeSocket.destroyed) { this.log.error({ msg: 'Write socket destroyed', cid: this.id }); this.close(); return; } // Append CRLF only to the final part of a command. When sending literals, // commandParts holds the remaining parts (literal data, continuation); the CRLF // delimiter is only added when no more parts remain (the command is complete). let addLineBreak = !this.commandParts.length; // What goes to the socket, one write per entry. A large Buffer (the data of a literal) // gets its CRLF as a write of its own, as concatenating would copy all of it. let frames; if (typeof chunk === 'string') { if (addLineBreak) { chunk += '\r\n'; } frames = [Buffer.from(chunk, 'binary')]; } else if (Buffer.isBuffer(chunk)) { if (!addLineBreak) { frames = [chunk]; } else { frames = chunk.length >= SEPARATE_LINE_BREAK_SIZE ? [chunk, CRLF] : [Buffer.concat([chunk, CRLF])]; } } else { return false; } this.lastClientWrite = Date.now(); for (let data of frames) { if (this.logRaw) { // Client frames of an authentication exchange carry credentials: the LOGIN // arguments, and for AUTHENTICATE also the continuation writes (SASL PLAIN // response, AUTH=LOGIN password, OAuth token payload) that bypass send(). The // parsed command log masks these, so the raw log must withhold them too, but // `data` still carries the placeholder rather than being dropped - the field is // part of the documented log format and consumers decode it unconditionally. this.log.trace({ src: 'c', msg: 'write to socket', data: this.rawSensitiveCommand ? RAW_HIDDEN_PLACEHOLDER : data.toString('base64'), ...(this.rawSensitiveCommand ? { hidden: true } : {}), compress: !!this._deflate, secure: !!this.secureConnection, cid: this.id }); } this.writeBytesCounter += data.length; this.writeSocket.write(data); } } /** * Returns byte counters for the current connection. * * @param reset If `true` then resets the byte counters after returning the current values * @returns Byte counters: bytes sent to and received from the server */ stats(reset) { let result = { sent: this.writeBytesCounter || 0, received: (this.streamer && this.streamer.readBytesCounter) || 0 }; if (reset) { this.writeBytesCounter = 0; if (this.streamer) { this.streamer.readBytesCounter = 0; } } return result; } // Compiles and sends an IMAP command to the server. The command is compiled // twice: once as an array (for sending, with literal data split into parts) // and once as a string (for logging, with sensitive data masked). // When LITERAL- or LITERAL+ extensions are available, the compiler can use // non-synchronizing literals to avoid waiting for server "+" continuation. // Async although nothing in it awaits: a failure inside it rejects one microtask later, so a // close() in the same tick settles the command with NoConnection first (see // test/unhandled-rejection-test.ts) /** @internal */ async send(data) { if (this.state === this.states.LOGOUT) { // Already logged out. Thrown rather than rejected here so trySend() clears the // request from currentRequest and goes on to the next one: a request left there // never gets a tagged response, and everything queued behind it would wait for // the socket watchdog. throw this.createNoConnectionError(false, { rejectedFrom: 'sendAfterLogout', command: data.command }); } // Classify once per command. Every frame of this command - the command line and any // continuation write that follows it - belongs to it until the next send(), because // trySend() keeps one command in flight at a time. Reading currentRequest inside write() // instead would be racy: rejectCurrentRequest() can clear it before a continuation frame // is written, and the credential frame would then be logged in the clear. // Uppercased because the wire protocol is case-insensitive and exec() passes the // caller's spelling through unchanged. The command list covers the mechanisms whose // secret arrives in a continuation frame, which carries no attributes of its own; the // `sensitive` marker catches anything that instead puts a secret on the command line, // so marking an attribute is enough to keep a new command out of the raw log too. this.rawSensitiveCommand = RAW_SENSITIVE_COMMANDS.has(typeof data.command === 'string' ? data.command.toUpperCase() : '') || hasSensitiveAttribute(data.attributes); // Compile with asArray=true: splits output into parts for literal handling. // First part is the command text up to the first literal, remaining parts // are stored in this.commandParts and sent after server "+" continuations. let compiled = compiler(data, { asArray: true, // LITERAL- is part of base IMAP4rev2 literalMinus: hasCapability(this, 'LITERAL-') || this.capabilities.has('LITERAL+') }); this.commandParts = compiled; /* c8 ignore next */ // send() is always invoked with a request object carrying options, so the {} fallback is unreachable let options = data.options || {}; // Compile again for logging with isLogging=true: masks sensitive values // like passwords while producing a human-readable command string if (this.isLogLevelEnabled('debug')) { let logCompiled = compiler(data, { isLogging: true }); this.log.debug({ src: 'c', msg: logCompiled.toString(), cid: this.id, comment: options.comment }); } // Send the first part (command text). If there are literal parts, // the server will respond with "+" continuations and reader() will // send each remaining part from this.commandParts. this.write(this.commandParts.shift()); // The command is on the wire now. Tagged-response correlation requires this, so a server // that guesses the next (sequential) tag cannot settle a command during the window between // it becoming current and actually being written. if (this.currentRequest && this.currentRequest.tag === data.tag) { this.currentRequest.sent = true; } if (typeof options.onSend === 'function') { // The command is already on the wire, so a throwing onSend callback must not // reach trySend()'s catch - that would reject the request and dispatch the // next command into the server's pending state for this one. try { options.onSend(); } catch (err) { this.log.warn({ err, cid: this.id }); } } } /** @internal */ async trySend() { while (!this.currentRequest && this.requestQueue.length) { this.currentRequest = this.requestQueue.shift(); try { await this.send({ tag: this.currentRequest.tag, command: this.currentRequest.command, attributes: this.currentRequest.attributes, options: this.currentRequest.options }); return; } catch (err) { // A failure here (most likely the compiler refusing an invalid // user-supplied value) belongs to the command that was being dispatched. // Without this the shifted request would stay currentRequest forever: // nothing reached the wire, so no tagged response ever clears it, and // every later command would queue behind it until the socket timeout. // Reject the failed command and keep draining the queue. this.rejectCurrentRequest(err); } } } /** @internal */ exec(command, attributes, options) { if (this.state === this.states.LOGOUT || this.isClosed) { return guardedReject(this.createNoConnectionError(false, { rejectedFrom: 'execClosed', command })); } if (!this.socket || this.socket.destroyed) { return guardedReject(this.createConnectionError('EConnectionClosed', 'Connection closed', { rejectedFrom: 'execNoSocket', command })); } let tag = (++this.tagCounter).toString(16).toUpperCase(); let execOptions = options || {}; // Guarded: close() rejects this request synchronously, possibly before the caller has // attached its handler. See guardedPromise(). return guardedPromise((resolve, reject) => { this.requestTagMap.set(tag, { command, attributes, options: execOptions, resolve, reject }); this.requestQueue.push({ tag, command, attributes, options: execOptions }); // trySend() settles dispatch failures itself, by rejecting the affected // command through requestTagMap; this catch exists only so a throw from the // dispatch machinery itself can never surface as a floating rejection. this.trySend().catch(err => logConnectionError(this, 'Failed to dispatch command', err)); }); } // Resolves an untagged server response to the keyword it is dispatched on. IMAP untagged // responses come in two forms: // * CAPABILITY ... (keyword as command) // * 42 FETCH (...) (numeric prefix + keyword) // For numeric-prefixed responses the keyword sits in the first attribute, because `command` // holds the sequence number. Also used for logging, so a failure reports FETCH rather than // the message number that happened to precede it. /** @internal */ normalizeUntaggedCommand(command, attributes) { if (/^[0-9]+$/.test(command)) { let type = attributes && attributes.length && typeof attributes[0].value === 'string' ? attributes[0].value.toUpperCase() : false; if (type) { command = type; } } return command.toUpperCase().trim(); } // Handler priority: command-specific handlers (registered per exec() call) take // precedence over global handlers (registered on the connection). /** @internal */ getUntaggedHandler(command, attributes) { command = this.normalizeUntaggedCommand(command, attributes); // Check command-specific handler first (registered in exec() options.untagged) if (this.currentRequest && this.currentRequest.options && this.currentRequest.options.untagged && this.currentRequest.options.untagged[command]) { return this.currentRequest.options.untagged[command]; } // Fall back to global handler (e.g., for CAPABILITY, BYE, etc.) let handler = this.untaggedHandlers[command]; if (handler) { return handler; } } /** @internal */ getSectionHandler(key) { if (this.sectionHandlers[key]) { return this.sectionHandlers[key]; } } // Releases a readable stream item exactly once. The item's `next` callback is the parser's // backpressure token: until it is called, ImapStream stops feeding the connection. Every // path out of response handling - success, handled error, or unexpected throw - has to go // through here, otherwise the parser stalls permanently. /** @internal */ releaseStreamData(data) { if (!data || data.released) { return; } data.released = true; if (typeof data.next === 'function') { data.next(); } } // Records a tagged response whose tag was never issued by this connection. ImapFlow talks // to a wide range of non-conforming servers, so this is tolerated rather than terminal, but // it must not pass silently. Warnings are emitted for the first occurrence and then at // powers of two so a server spraying stray tagged lines cannot flood the log, while the // counter itself stays exact and is reported when the connection closes. /** @internal */ countUnknownTag(tag) { if (this.isClosed) { // teardown crossover, not a server compatibility signal return; } this._unknownTagCount++; if (this._unknownTagCount === this._nextUnknownTagWarn) { this._nextUnknownTagWarn *= 2; this.log.warn({ msg: 'Tagged response for an unknown tag', tag, unknownTagCount: this._unknownTagCount, cid: this.id }); } } // Terminally fails the connection on a protocol violation: stop parsing, then report. Both // steps are explicit here rather than destroying the parser *with* the error and relying on // its error listener to report, so the reporting path does not depend on teardown ordering or // on the streamer error handler's suppression list. /** @internal */ failProtocol(err) { if (this.streamer && !this.streamer.destroyed) { // Destroyed without an error: nothing after a protocol violation may reach // application state, and emitError() below owns reporting. this.streamer.destroy(); } this.emitError(err); } // Rejects the in-flight request, if any, exactly once. Used when response handling fails in // a way that leaves the command's outcome unknown. /** @internal */ rejectCurrentRequest(err) { let request = this.takeCurrentRequest(); if (request) { request.reject(err); } } /** * Takes the in-flight command off the connection: its tag map entry, and the literal parts it * still had to send (a tagged NO before the "+" of a refused APPEND leaves them behind, and * they must neither stay referenced nor answer a later continuation). The parts are dropped * whether or not a command is still current, as a failed dispatch may have cleared it already. * * @returns The pending request entry, if the command was still current * @internal */ takeCurrentRequest() { this.commandParts = []; if (!this.currentRequest) { return undefined; } let tag = this.currentRequest.tag; this.currentRequest = false; let request = this.requestTagMap.get(tag); this.requestTagMap.delete(tag); return request; } /** * Resolves a command's promise with its tagged response and parks the reader until the * command's handler calls next(). The release is kept in parkedRelease so a handler that * throws before calling next() can be unparked (see releaseOrphanedResponse()); every path * that resolves a command this way goes through here for that reason. * * @param request - Pending request entry * @param parsed - Parsed tagged response * @param hasTrailingData - Whether more input was already buffered after this line * @internal */ resolveParked(request, parsed, hasTrailingData) { return new Promise(resolve => { let release = () => { if (this.parkedRelease === release) { this.parkedRelease = null; } resolve(); }; this.parkedRelease = release; request.resolve({ response: parsed, next: release, hasTrailingData }); }); } /** * Waits out a throttle back-off. * * The delay is capped at MAX_THROTTLE_DELAY because it can come straight from a server hint * (a Microsoft 365 "Suggested Backoff Time", say) and an uncapped hint would park the caller * for weeks. The timer is unref'd and tracked so it can never outlive the client: a bare * setTimeout here keeps a short-lived process alive for the full delay after close(), and * leaves the caller waiting on a connection that is already gone. * * @param delay - Requested delay in milliseconds. * @returns True if close() aborted the wait, false on normal expiry. * @internal */ async throttleWait(delay) { delay = Math.min(Math.max(Number(delay) || 0, 0), MAX_THROTTLE_DELAY); return await new Promise(resolve => { let entry = { resolve }; entry.timer = setTimeout(() => { this._throttleWaits.delete(entry); resolve(false); }, delay); unrefTimer(entry.timer); this._throttleWaits.add(entry); }); } /** @internal */ async reader() { let data; let processedCount = 0; while ((data = this.streamer.read()) !== null) { let keepReading; try { // The reader is parked here for as long as processing takes (a fetch consumer // working on a row), so a socket that goes quiet meanwhile is this side's doing, // see _socketTimeout this._processingResponse = true; keepReading = await this.handleResponse(data); } catch (err) { // Response handling past the parse step (log compilation, response shape // assumptions, an untagged handler bug) must never throw out of this loop: the // parser would keep waiting on its backpressure callback forever, which is a // silent permanent hang. Fail closed instead. keepReading = false; let error = createImapError('Failed to process server response', 'ResponseProcessingFailed', { _err: err }); this.log.error({ msg: 'Failed to process server response', err, cid: this.id }); this.rejectCurrentRequest(error); this.failProtocol(error); } finally { this._processingResponse = false; this.releaseStreamData(data); } if (!keepReading) { return; } // Yield to event loop every 10 processed messages to prevent CPU blocking processedCount++; if (processedCount % 10 === 0) { await new Promise(resolve => setImmediate(resolve)); } } } /** * Fails the in-flight command when a line that could not be parsed was addressed to its tag. * Only the leading tag is read from the raw payload - the rest of the line is by definition * not trustworthy - and only the command that is actually on the wire may be settled this way, * the same invariant the parsed tagged-response path enforces. * * @param payload - Raw bytes of the line that failed to parse. * @param parserError - The error the parser raised. * @internal */ rejectUnparsedCompletion(payload, parserError) { if (!this.currentRequest || !this.currentRequest.sent) { return false; } // Prefer the tag the parser had already extracted before it failed - it went // through the same leading-NUL workaround as every parsed response. Fall back // to the raw bytes for lines whose tag itself was unparseable: skip the NUL // padding buggy servers prepend and stop at the first byte a tag cannot contain. let tag = parserError && parserError.parsedTag; if (!tag) { let match = payload.toString('latin1', 0, 64).match(/^\0*([^\s\x00-\x1f\x7f]+)/); tag = match && match[1]; } if (!tag || tag !== this.currentRequest.tag) { return false; } let err = new Error('Failed to parse the server response for this command'); err.code = parserError.code || 'ParserError'; err.parserError = parserError; this.failInFlight(err); return true; } /** * Fails the in-flight command when the server answered it with a NO or BAD status line that * has no tag at all (Namecheap Private Email: `BAD "Must issue a STARTTLS command first"`). * Only one command is ever on the wire, and leaving it pending would stall the connection * until the socket timeout. Tags are uppercase hex, so a command can carry the tag BAD: * callers check for the command's own tag first. * * @param payload - Raw bytes of the line. * @returns `true` when the line failed the in-flight command * @internal */ rejectTaglessCompletion(payload) { if (!this.currentRequest || !this.currentRequest.sent) { return false; } let match = payload.toString('latin1', 0, 1024).match(/^\0*(BAD|NO)(?:\s+([^]*))?$/i); if (!match) { return false; } let err = createImapError('Command failed', 'MissingTag', { command: this.currentRequest.command, responseStatus: match[1].toUpperCase(), responseText: (match[2] || '').trim().replace(/^"(.*)"$/, '$1') }); this.log.warn({ msg: 'Server sent a status response without a tag', err, cid: this.id }); this.failInFlight(err); return true; } // Rejects the in-flight command and moves on to the next queued one /** @internal */ failInFlight(err) { this.rejectCurrentRequest(err); this.trySend().catch(sendErr => logConnectionError(this, 'Failed to dispatch command', sendErr)); } // RFC 9051 section 7.1: the text of an ALERT must be presented to the user. It can come // with any status response, tagged or untagged, the greeting and BYE included. Called for // responses that carry the ALERT code. /** @internal */ reportAlert(parsed) { let response = (parsed.command || '').toUpperCase(); if (!ALERT_RESPONSES.has(response)) { return; } let alert = { message: getTextValues(parsed.attributes) .map(value => value.trim()) .join(' '), response: response }; if (parsed.tag && parsed.tag !== '*') { alert.tag = parsed.tag; } this.log.info({ msg: 'Server alert', alert: alert.message, response: alert.response, cid: this.id }); emitSafe(this, 'alert', alert); } /** * Handles a single parsed server response: telemetry, continuation requests, response-code * section handlers, untagged handlers and tagged command completion. * * @param data - Readable item from the parser stream. * @returns `true` to keep reading, `false` to stop (connection is failing). * @internal */ async handleResponse(data) { let parsed; try { parsed = await parser(data.payload, { literals: data.literals, maxResponseTokens: this.options.maxResponseTokens }); } catch (err) { if (err.code === 'TooManyTokens') { // A size limit, so as terminal as the ones ImapStream enforces: the server is // flooding responses, and skipping the line would only invite the next one this.log.error({ msg: 'Response exceeded the token limit', payloadBytes: data.payload.length, err, cid: this.id }); this.rejectCurrentRequest(err); this.failProtocol(err); return false; } // can not make sense of this. The payload can be up to the configured line // cap (1GB by default), so log only a bounded prefix: a server looping // unparseable garbage would otherwise turn this error log into a disk filler. this.log.error({ src: 's', msg: data.payload.toString('latin1', 0, 1024), payloadBytes: data.payload.length, err, cid: this.id }); // An unparseable untagged line is junk that can be skipped, but the line may // have been the in-flight command's tagged completion. Dropping that one // silently strands the command: currentRequest is never cleared, so trySend() // stops dispatching and every later command queues behind it until the socket // timeout fires. The tag is recovered from the raw bytes (a tag is // ASTRING-CHAR only, so it survives whatever made the rest unparseable) and // the command is failed with the parser error instead of hanging. if (!this.rejectUnparsedCompletion(data.payload, err)) { this.rejectTaglessCompletion(data.payload); } return true; } if (parsed.truncatedAttributes) { // The row is kept with the item missing rather than dropped with the whole line: the // nesting is the message sender's doing, and the message must still reach the caller this.log.warn({ msg: 'FETCH data item exceeded the nesting limit and was dropped', seq: parsed.command, attributes: parsed.truncatedAttributes, cid: this.id }); } if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) { let payload = { response: parsed.command }; if (parsed.attributes && parsed.attributes[0] && parsed.attributes[0].section && parsed.attributes[0].section[0] && parsed.attributes[0].section[0].type === 'ATOM') { payload.code = parsed.attributes[0].section[0].value; } // Outside the parse try/catch on purpose: a throwing user 'response' listener // is not a parse failure and must not settle the in-flight command or fail the // connection - the same contract untagged handlers get. try { this.emit('response', payload); } catch (err) { this.log.warn({ err, cid: this.id }); } } // FETCH responses are the bulk of a session, so they log at trace. The serialization is // skipped when the level is off. let logLevel = /^\d+$/.test(parsed.command || '') && parsed.attributes && parsed.attributes[0] && parsed.attributes[0].value === 'FETCH' ? 'trace' : 'debug'; if (this.isLogLevelEnabled(logLevel)) { let logCompiled = compiler(parsed, { isLogging: true }); this.log[logLevel]({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved }); } // IMAP "+" (continuation request) handling. The server sends "+" in two cases: // 1. During IDLE or AUTHENTICATE, where a custom handler (onPlusTag) processes it // 2. During literal data transfer, where we send the next queued literal chunk if (parsed.tag === '+' && this.currentRequest && this.currentRequest.options && typeof this.currentRequest.options.onPlusTag === 'function') { try { await this.currentRequest.options.onPlusTag(parsed); } catch (err) { // The handler ran across an await and may have closed the connection, which // clears currentRequest, so the command name is read defensively this.log.warn({ msg: 'Failed to process continuation response', command: this.currentRequest ? this.currentRequest.command : undefined, err, cid: this.id }); } return true; } // Server acknowledged our literal size with "+", send the actual literal data if (parsed.tag === '+' && this.commandParts.length) { let content = this.commandParts.shift(); // A write() failure here (e.g. socket closed mid-command) must not fail the whole // connection; the command's own tagged response or the close path reports it. try { this.write(content); this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id }); } catch (err) { logConnectionError(this, 'Failed to send literal continuation', err); } return true; } let section = parsed.attributes && parsed.attributes.length && parsed.attributes[0] && !parsed.attributes[0].value && parsed.attributes[0].section; // section[0] can be a parsed NIL (null), e.g. from a "[NIL]" response code - the // dereference must be guarded or one such line tears down the whole connection if (section && section.length && section[0] && section[0].type === 'ATOM' && typeof section[0].value === 'string') { let sectionKey = section[0].value.toUpperCase().trim(); if (sectionKey === 'ALERT') { this.reportAlert(parsed); } let sectionHandler = this.getSectionHandler(sectionKey); if (sectionHandler) { try { await sectionHandler(section.slice(1)); } catch (err) { this.log.warn({ msg: 'Failed to process response section', section: sectionKey, err, cid: this.id }); } } } if (parsed.tag === '*' && parsed.command) { let untaggedHandler = this.getUntaggedHandler(parsed.command, parsed.attributes); if (untaggedHandler) { try { await untaggedHandler(parsed); } catch (err) { // Normalized only here: this runs for every untagged response, including // every FETCH, and the keyword is needed only to describe a failure this.log.warn({ msg: 'Failed to process untagged response', command: this.normalizeUntaggedCommand(parsed.command, parsed.attributes), err, cid: this.id }); return true; } } } // Tagged response correlation. A tagged response may only complete the command that was // actually written to the socket (invariant 2), so the three cases below are kept apart: // the active command completes, a command that has not been written yet is proof of // desynchronization (queued behind another command, or current but not yet on the wire), // and an entirely unknown tag is recorded but tolerated, unless the line is a NO or BAD // that came without a tag (see rejectTaglessCompletion()). if (parsed.tag && !['*', '+'].includes(parsed.tag)) { if (this.currentRequest && this.currentRequest.tag === parsed.tag && this.currentRequest.sent) { let request = this.takeCurrentRequest(); if (request) { await this.settleRequest(request, parsed, !!data.trailingAfterLine); } // Send the next queued command only after the completed command's handler has // applied its own state (e.g. select.ts publishing the new mailbox), so the next // command cannot reach the wire against half-updated state. A failure here must // not propagate, or the whole connection would be failed over a send error that // the command's own promise already reports. // Note: on a rejected command the handler's catch block runs on its own microtask // chain, so only the success path is fully ordered. try { await this.trySend(); } catch (err) { this.log.warn({ err, cid: this.id }); } } else if (this.requestTagMap.has(parsed.tag)) { // The server answered a command that has not been written to the socket yet. // Continuing would report unsent mutations as successful and leave every later // response ambiguous, so reject this request and fail the connection closed. let request = this.requestTagMap.get(parsed.tag); this.requestTagMap.delete(parsed.tag); let err = createImapError('Server sent a tagged response for a command that was not in flight', 'UnexpectedTag', { details: { received: parsed.tag, expected: this.currentRequest ? this.currentRequest.tag : null } }); this.log.error({ msg: 'Protocol desynchronization', err, cid: this.id }); request.reject(err); this.failProtocol(err); return false; } else if (!this.rejectTaglessCompletion(data.payload)) { this.countUnknownTag(parsed.tag); } } return true; } /** * Settles a request with its tagged completion response. * * On success the returned promise stays pending until the command handler calls `next()` on * the response, which is what orders state application before the next queued command is * dispatched. A command handler must therefore always release its own response before * awaiting another command on the same connection. * * @param request - Pending request entry (resolve/reject and the compiled command). * @param parsed - Parsed tagged response. * @param hasTrailingData - Whether more input was already buffered after this line. * @internal */ async settleRequest(request, parsed, hasTrailingData) { switch ((parsed.command || '').toUpperCase()) { case 'OK': // hasTrailingData is forwarded so STARTTLS can detect a plaintext // injection (data buffered after the tagged OK, before the handshake). await this.resolveParked(request, parsed, hasTrailingData); break; case 'BYE': { // BYE is always untagged (RFC 9051 section 7.1.5), it is never the completion // result of a command. It used to resolve the command like an OK, so a server (or // a cleartext MITM) answering LOGIN with a tagged BYE marked the session // authenticated. The server says it is closing: the command fails as a lost // connection and the connection is closed, which for a LOGOUT is the outcome it // was after anyway. await this.serverBye(parsed); let err = this.createNoConnectionError(this.byeReason, { rejectedFrom: 'taggedBye', command: request.command }); err.response = parsed; err.responseStatus = 'BYE'; request.reject(err); break; } case 'NO': case 'BAD': { let txt = getTextValues(parsed.attributes) .map(val => val.trim()) .join(' '); let err = new Error('Command failed'); err.response = parsed; err.responseStatus = parsed.command.toUpperCase(); try { err.executedCommand = parsed.tag + compiler(request, { isLogging: true }).toString(); } catch { // ignore } if (txt) { err.responseText = txt; if (err.responseStatus === 'NO' && txt.includes('Some of the requested messages no longer exist')) { // Treat as successful response // Kept at warn: the caller is handed fewer messages than it asked for and // is told nothing else about it, so this entry is the only record that // the response was truncated. this.log.warn({ msg: 'Partial FETCH response', cid: this.id, err }); await this.resolveParked(request, parsed); break; } let throttleDelay = false; // MS365 throttling detection: Office 365 returns BAD with a human-readable // backoff time when rate limits are hit. Parse the delay from the response text. // Example: "tag BAD Request is throttled. Suggested Backoff Time: 92415 milliseconds" if (/Request is throttled/i.test(txt) && /Backoff Time/i.test(txt)) { let throttlingMatch = txt.match(/Backoff Time[:=\s]+(\d+)/i); if (throttlingMatch) { throttleDelay = Number(throttlingMatch[1]); } } // Wait and return a throttling error if (throttleDelay) { err.code = 'ETHROTTLE'; err.throttleReset = throttleDelay; // The server-suggested delay can be very large, so throttleWait() caps it. // The reader loop is parked for the whole wait, so it also stays well // inside the socket inactivity timeout: a wait that outlasted it would // fire the timeout handler, and the keepalive NOOP it sends can not be // read while the loop is parked, so the connection would be torn down. // A caller that retries (fetch) waits out the rest of the hint itself. let delayResponse = Math.min(throttleDelay, MAX_THROTTLE_DELAY, Math.floor(this.socketTimeout / 2)); err.throttleWaited = delayResponse; this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err }); let aborted = await this.throttleWait(delayResponse); if (aborted) { // Connection closed during back-off: reject promptly with a // connection error (carrying any server BYE reason) instead of // waiting out the throttle delay. request.reject(this.createNoConnectionError(this.byeReason, { rejectedFrom: 'throttleAbort', command: request.command })); break; } } } request.reject(err); break; } default: { let err = createImapError('Invalid server response', 'InvalidResponse', { response: parsed }); request.reject(err); break; } } } /** @internal */ setEventHandlers() { // Bind the 'readable' event to kick off the reader loop. // The `this.reading` flag acts as a concurrency guard: if reader() // is already running, new 'readable' events are ignored. The reader // loop will keep draining data until the stream returns null. const onReadable = () => { if (!this.reading) { this.reading = true; this.reader() .catch(err => this.log.error({ err, cid: this.id })) .finally(() => { this.reading = false; // A 'readable' event that fired while the loop was winding down was // ignored by the guard above. Node emits the event on the next tick, // after this handler has run, but a runtime that implements nextTick // as a microtask (Cloudflare Workers) emits it before, and the response // the parser had pushed in the meantime would then sit unread until the // next chunk arrives - or, for the last response of an exchange, until // the socket times out. Anything already buffered is picked up here. if (this.streamer && !this.streamer.destroyed && this.streamer.readableLength > 0) { onReadable(); } }); } }; this.socketReadable = onReadable; this.streamer.on('readable', onReadable); } /** * Applies the transport options every established application socket needs: TCP keepalive and * the inactivity watchdog. Called for direct TLS, cleartext, proxied and STARTTLS-upgraded * sockets, so the watchdog cannot silently differ between transports (a STARTTLS session used * to end up with no armed timer at all). * * @param socket - The socket that now carries the IMAP session. * @internal */ configureSocket(socket) { /* c8 ignore next 3 */ // defensive: connect() only calls this with an established socket if (!socket) { return; } if (typeof socket.setKeepAlive === 'function') { socket.setKeepAlive(true, 5 * 1000); } this.armSocketTimeout(socket); } /** * Arms the inactivity watchdog on a socket. Guarded the same way as the rest of * configureSocket(): other runtimes stub socket features. * * @param socket - The socket to arm * @internal */ armSocketTimeout(socket) { if (typeof socket.setTimeout === 'function') { socket.setTimeout(this.socketTimeout); } else { // a transport socket from plain JavaScript can lack it, which disables the watchdog this.log.warn({ msg: 'The socket has no setTimeout(), socketTimeout is not enforced', cid: this.id }); } } /** @internal */ setSocketHandlers() { // Clear any existing handlers first to prevent duplicates this.clearSocketHandlers(); this._socketError = this._socketError || ((err) => { this.log.error({ err, cid: this.id }); this.emitError(err); }); this._socketClose = this._socketClose || (() => this.close()); this._socketEnd = this._socketEnd || (() => this.close()); /** * Socket timeout event handler. * * A quiet socket is only a dead connection when something was supposed to be talking. An * idling session, a download whose consumer stopped draining, and a held mailbox lock * whose owner is busy between commands are all expected to go quiet, so the handler keeps * such a connection alive with a NOOP instead of tearing it down. An in-flight command is * the opposite: its reply is overdue, a recovery NOOP would only queue up behind it and * never reach the wire, so the timeout is reported as an error. The IDLE command itself is * the one exception - it stays in flight for as long as idling lasts, and run() breaks it * through preCheck() before the NOOP is dispatched. * * IDLE is not restarted here: run() re-arms auto-IDLE once the NOOP settles, and * autoidle() knows whether the connection is actually free for IDLE - an open download or * a held lock keeps just the keepalive, and with disableAutoIdle nothing restarts at all. * If the server is dead the NOOP never settles, and the next timeout fires with the NOOP * as the stuck in-flight command, which lands in the error branch below. * * Emits the error event if the connection cannot be recovered */ this._socketTimeout = this._socketTimeout || (() => { const timeoutError = () => createImapError('Socket timeout', 'ETIMEOUT'); if (!this.usable || !this.socket || this.socket.destroyed) { this.emitError(timeoutError()); return; } if (this._processingResponse) { // The reader is parked processing a response (a fetch consumer taking its // time over a row), so nothing is being read: the quiet is this side's, and // the server is not overdue with anything. The timer is one-shot and nothing // resumes it until reading does, so it is re-armed for the next check. this.log.debug({ msg: 'Socket timeout while a response is being processed', cid: this.id }); this.armSocketTimeout(this.socket); return; } // A throttle back-off is quiet by design, the NOOP below probes that the server // is still there const quietExpected = this.idling || this._openDownloads || this.currentLock || this._throttleWaits.size; const commandStuck = this.currentRequest && !(this.idling && this.currentRequest.command === 'IDLE'); // A recovery NOOP that has not settled by the next timeout means the peer is gone if (quietExpected && !commandStuck && !this._recoveryPending) { // The inactivity timer is one-shot, re-armed only by traffic. The NOOP can not // reach the wire while IDLE still waits for its continuation (DONE is sent // only after the "+"), so without re-arming it here a peer that went silent // right after IDLE would never time out again. this._recoveryPending = true; this.armSocketTimeout(this.socket); this.run('NOOP') .catch(err => { this.log.warn({ msg: 'Connection recovery failed after timeout', err, cid: this.id }); if (!this.isClosed) { this.close(); } }) .finally(() => { this._recoveryPending = false; }); } else { this.log.debug({ msg: 'Socket timeout', cid: this.id }); this.emitError(timeoutError()); } }); const socket = this.socket; // on(), not once(): with compression the deflate and writeSocket errors are forwarded // onto this socket, and a second one before close() must not find it without a listener socket.on('error', this._socketError); socket.once('close', this._socketClose); socket.once('end', this._socketEnd); socket.on('tlsClientError', this._socketError); socket.on('timeout', this._socketTimeout); if (this.writeSocket && this.writeSocket !== this.socket) { this.writeSocket.on('error', this._socketError); } } /** @internal */ clearSocketHandlers() { if (!this.socket) { return; } // Remove temporary connection error handler if still present if (this._connectErrorHandler) { this.socket.removeListener('error', this._connectErrorHandler); this._connectErrorHandler = null; } if (this._socketError) { this.socket.removeListener('error', this._socketError); this.socket.removeListener('tlsClientError', this._socketError); if (this.writeSocket && this.writeSocket !== this.socket) { this.writeSocket.removeListener('error', this._socketError); } } if (this._socketTimeout) { this.socket.removeListener('timeout', this._socketTimeout); } if (this._socketClose) { this.socket.removeListener('close', this._socketClose); } if (this._socketEnd) { this.socket.removeListener('end', this._socketEnd); } } /** @internal */ async startSession() { await this.run('CAPABILITY'); await this.upgradeToSTARTTLS(); // ID goes after STARTTLS: the client info should not travel in cleartext, and some // servers refuse every command but STARTTLS before the upgrade with an untagged BAD // the client can not tie to ID, which then waits for the socket timeout (Namecheap // Private Email). The capability list was refreshed by the upgrade. if (this.capabilities.has('ID')) { this.idRequested = await this.run('ID', this.clientInfo); } await this.authenticate(); if ((!this.idRequested || Object.keys(this.idRequested).length < 2) && this.capabilities.has('ID')) { // re-request ID after LOGIN this.idRequested = await this.run('ID', this.clientInfo); } let serverName = this.serverInfo?.name; if (!this.skipRev2 && typeof serverName === 'string' && BROKEN_REV2_SERVERS.has(serverName.trim().toLowerCase())) { // Same effect as disableIMAP4rev2, decided before autoEnable() can send the ENABLE this.skipRev2 = true; this.log.info({ msg: 'Not enabling IMAP4rev2, the server is known to answer SEARCH incorrectly in that mode', server: serverName, cid: this.id }); } // Make sure we have namespace set. This should also throw if Exchange actually failed authentication let nsResponse = await this.run('NAMESPACE'); if (nsResponse && nsResponse.error && nsResponse.status === 'BAD' && /User is authenticated but not connected/i.test(nsResponse.text)) { // Not a NAMESPACE failure but authentication failure, so report as this.authenticated = false; let err = new AuthenticationFailure('Authentication failed'); err.response = nsResponse.text; throw err; } if (this.options.verifyOnly) { // List all folders and logout if (this.options.includeMailboxes) { this._mailboxList = await this.list(); } return await this.logout(); } // try to use compression (if supported) if (!this.options.disableCompression) { await this.compress(); } if (!this.options.disableAutoEnable) { await this.autoEnable(); } this.usable = true; } // Enable extensions if possible. IMAP4rev2 must be enabled explicitly on // servers that advertise both rev1 and rev2 (RFC 9051 Appendix A); a single // ENABLE call is used so the enabled set is built in one round trip. /** @internal */ async autoEnable() { let enableList = ['CONDSTORE', 'UTF8=ACCEPT'].concat(this.options.qresync ? 'QRESYNC' : []).concat(this.skipRev2 ? [] : 'IMAP4rev2'); let enableResult = await this.run('ENABLE', enableList); if (enableResult === false && enableList.includes('IMAP4rev2')) { if (this.capabilities.has('IMAP4rev2') && !isRev2Active(this)) { // The one command that would have made this a rev2 session was // rejected, so it stays an IMAP4rev1 session (RFC 9051 Appendix A) // with an advertisement the server does not implement - see skipRev2 this.skipRev2 = true; this.skipLsub = true; } // RFC 5161 requires servers to ignore unknown ENABLE arguments, but a // broken implementation may reject the whole command over IMAP4rev2 - // retry without it so CONDSTORE/QRESYNC are not lost as collateral await this.run('ENABLE', enableList.filter(extension => extension !== 'IMAP4rev2')); } } /** @internal */ async compress() { if (typeof zlib.createDeflateRaw !== 'function') { // The runtime has no streaming DEFLATE (celld implements only the synchronous zlib // functions), so the session stays uncompressed rather than failing after COMPRESS this.log.debug({ msg: 'Streaming DEFLATE is not available, skipping COMPRESS', cid: this.id }); return; } if (!(await this.run('COMPRESS'))) { return; // was not able to negotiate compression } // Set up DEFLATE compression (RFC 4978). After COMPRESS is negotiated, // all data in both directions is wrapped in a zlib DEFLATE stream. // The incoming pipeline becomes: socket -> inflate -> streamer (parser). // The outgoing pipeline uses a manual pump (see readNext below) instead // of a normal pipe, because we need to flush after every IMAP command // to ensure the server receives complete commands promptly. this._deflate = zlib.createDeflateRaw({ windowBits: 15, level: zlib.constants.Z_DEFAULT_COMPRESSION, // Use default compression level (6) memLevel: 8, // Memory usage level (8 is default) strategy: zlib.constants.Z_DEFAULT_STRATEGY, chunkSize: 16 * 1024 // Process in 16KB chunks to prevent CPU blocking }); this._inflate = zlib.createInflateRaw({ chunkSize: 16 * 1024 // Process in 16KB chunks to prevent CPU blocking }); const socket = this.socket; // Reroute incoming data through inflate: socket -> inflate -> streamer. // The streamer's compress flag tells it to expect deflated framing. socket.unpipe(this.streamer); this.streamer.compress = true; socket.pipe(this._inflate).pipe(this.streamer); this._inflate.on('error', err => { // Only forward into the streamer while it is alive and still has an error // listener. After close() the streamer is destroyed and its listener removed, // so emitting 'error' would throw an unhandled error and crash the process. // (this.streamer is assigned once in the constructor and never nulled.) if (!this.streamer.destroyed && this.streamer.listenerCount('error')) { this.streamer.emit('error', err); } }); // For outgoing data, replace the writeSocket with a PassThrough buffer. // We can't pipe writeSocket -> deflate -> socket directly because we need // to call deflate.flush() after each IMAP command to push all pending // compressed bytes to the server immediately (IMAP is request-response). const writeSocket = new PassThrough({ highWaterMark: 64 * 1024, // 64KB buffer limit to prevent excessive memory usage // read() hands out each written chunk as is: in byte mode it joins whatever is // queued into a new Buffer, which copies every large literal once more readableObjectMode: true }); this.writeSocket = writeSocket; /* c8 ignore start */ // destroySoon override is never invoked by ImapFlow (close() calls destroy()); kept for stream API completeness writeSocket.destroySoon = () => { try { if (this.socket) { this.socket.destroy(); } writeSocket.end(); } catch (err) { this.log.error({ err, msg: 'Failed to destroy PassThrough socket', cid: this.id }); throw err; } }; /* c8 ignore stop */ // The PassThrough reports its own `destroyed` state. It used to proxy the raw socket's // instead, which made close() skip destroying it and left the second raw-socket teardown // branch unreachable. write() checks the raw socket separately, so nothing depends on the // two states being conflated. // Manual pump loop: reads chunks from writeSocket, pushes them into // deflate, and flushes when the buffer is drained. This ensures each // IMAP command is fully compressed and flushed to the socket immediately. let reading = false; let processedChunks = 0; let readNext = async () => { try { reading = true; processedChunks = 0; let chunk; while (this.writeSocket && (chunk = this.writeSocket.read()) !== null) { if (this._deflate && this._deflate.write(chunk) === false) { this._deflate.once('drain', () => { void readNext(); }); return; } // Yield to event loop every 100 chunks to prevent CPU blocking processedChunks++; /* c8 ignore next 6 */ // requires 100+ queued chunks in a single pump pass; not reproducible deterministically if (processedChunks % 100 === 0) { await new Promise(resolve => setImmediate(resolve)); if (!this.writeSocket) { break; } } } // flush data to socket if (this._deflate) { this._deflate.flush(); } reading = false; /* c8 ignore next 3 */ // defensive: the pump body does not throw under normal operation } catch (ex) { this.emitError(ex); } }; writeSocket.on('readable', () => { if (!reading && this.writeSocket) { // readNext() reports its own failures, the returned promise never rejects void readNext(); } }); writeSocket.on('error', err => { if (this.socket) { this.socket.emit('error', err); } }); this._deflate.pipe(socket); this._deflate.on('error', err => { if (this.socket) { this.socket.emit('error', err); } }); } /** @internal */ _failSTARTTLS(reason = 'Server does not support STARTTLS') { if (this.options.doSTARTTLS === true) { // STARTTLS configured as requirement let err = new Error(reason); err.tlsFailed = true; throw err; } // Opportunistic STARTTLS not offered. The capability list arrived in cleartext, so this // may be a downgrade attack: say so rather than fall back silently (see the doSTARTTLS // option for the policy). this.log.warn({ msg: reason + ', continuing over an unencrypted connection', host: this.host, port: this.port, cid: this.id }); return false; } /** * Tries to upgrade the connection to TLS using STARTTLS. * @throws if STARTTLS is required, but not possible. * @returns true, if the connection is now protected by TLS, either direct TLS or STARTTLS. */ async upgradeToSTARTTLS() { if (this.options.doSTARTTLS === true && this.options.secure === true) { throw new Error('Misconfiguration: Cannot set both secure=true for TLS and doSTARTTLS=true for STARTTLS.'); } if (this.secureConnection) { // Already using direct TLS. No need for STARTTLS. return true; } if (this.options.doSTARTTLS === false) { // STARTTLS explictly disabled by config return false; } if (this.state !== this.states.NOT_AUTHENTICATED) { // STARTTLS is only valid in the not-authenticated state (RFC 9051 section 6.2.1), // which a PREAUTH greeting skips, so there is no way left to protect this session. // Required STARTTLS fails here with that reason: the greeting arrived in cleartext, // and a MITM can answer with PREAUTH precisely to keep it that way. return this._failSTARTTLS('STARTTLS is not possible after a PREAUTH greeting'); } if (!this.capabilities.has('STARTTLS')) { return this._failSTARTTLS(); } if (this.options.transport && !this.options.transport.upgrade) { return this._failSTARTTLS('The transport can not start TLS'); } this.expectCapabilityUpdate = true; if (this.options.transport?.prepareUpgrade && this.socket) { this.options.transport.prepareUpgrade(this.socket); } let canUpgrade = await this.run('STARTTLS'); if (!canUpgrade) { return this._failSTARTTLS(); } // STARTTLS plaintext-injection guard (RFC 3501 section 6.2.1): a compliant server stays // silent after the tagged STARTTLS OK until the TLS handshake, so any data that // followed the OK was injected by a MITM and must not be treated as if it arrived // over TLS. Two complementary best-effort checks fail closed before wrapping the // socket; injection that still races in afterwards corrupts the TLS handshake and // is rejected there instead (with a generic TLS error rather than STARTTLS_INJECTION). const failSTARTTLSInjection = () => { let err = createImapError('Server sent data after the STARTTLS response and before the TLS handshake; possible plaintext-injection attack', 'STARTTLS_INJECTION', { tlsFailed: true }); this.closeAfter(); return err; }; // Check 1: the parser saw more input already buffered right after the tagged OK // (same TCP segment, or an already-queued chunk) - see hasTrailingData / starttls.ts. if (this._starttlsHadTrailingData) { throw failSTARTTLSInjection(); } const socketPlain = this.socket; // STARTTLS upgrade sequence: detach the plain socket from the parser, // wrap it in a TLS socket, then reconnect the new TLS socket to the // parser. The plain socket becomes the underlying transport for TLS. socketPlain.unpipe(this.streamer); // Check 2: now that the parser is detached, any bytes still buffered on the plain // socket arrived after the OK and were not consumed by the handshake - i.e. injected. // This catches late/fragmented injection that the parse-time snapshot cannot see. let injectedTail = typeof socketPlain.read === 'function' ? socketPlain.read() : null; /* c8 ignore next 3 */ // late/fragmented post-OK injection is timing-dependent and not deterministically reproducible if (injectedTail && injectedTail.length) { throw failSTARTTLSInjection(); } let upgraded = await new Promise((resolve, reject) => { let opts = Object.assign({ socket: socketPlain, // host is required even though the socket is already connected: without // it, a connection made to an IP literal (servername=false) has its // certificate verified against Node's fallback name "localhost" instead // of the IP - accepting any "localhost" certificate for any IP-hosted // server, and rejecting legitimate IP-SAN certificates. host: this.host, port: this.port }, this.tlsServername(), this.options.tls || {}); this.clearSocketHandlers(); let settled = false; // Single settlement path for the upgrade. Every terminal outcome - handshake // success, an error on the plain or the TLS socket, the upgrade timeout, an // explicit close(), or a streamer error routed here by emitError() - goes through // this helper exactly once. It owns clearing the upgrade timer, the exposed // rejector, the `upgrading` flag and the temporary handshake handlers, so a late // socket event cannot re-enter an already settled upgrade or leave state behind. const settle = (err, result) => { if (settled) { // A failed handshake can produce more than one error (Bun emits ECONNRESET on // the TLS socket after the one that settled the upgrade). settle() stays the // error listener of both sockets until they are torn down, so later errors // end up here instead of being thrown as unhandled 'error' events. if (err) { this.log.debug({ msg: 'Socket error after the TLS upgrade was settled', err, cid: this.id }); } return; } settled = true; clearTimer(this.upgradeTimeout); this.upgradeTimeout = null; this.upgrading = false; this._upgradeReject = null; if (!err) { // the generic socket handlers took over in the success callback socketPlain.removeListener('error', settle); if (this.socket && this.socket !== socketPlain) { this.socket.removeListener('error', settle); } } if (err) { clearTimer(this.connectTimeout); // Preserve the original error, marked as a TLS failure so callers can tell // an upgrade failure from an ordinary command failure. err.tlsFailed = true; this.closeAfter(); return reject(err); } resolve(result); }; // Exposed so emitError() and close() can settle the upgrade through the same path. this._upgradeReject = settle; // An error on either socket settles the upgrade, so settle() is the listener itself: // one function, one settlement, and removeListener() in settle() needs no separate // handler references. A TLS handshake failure (bad certificate, protocol mismatch) // is emitted on the new TLS socket rather than on the plain one, so both are covered. socketPlain.on('error', settle); /* c8 ignore start */ // UPGRADE_TIMEOUT is 10s; firing it deterministically would make the test suite hang this.upgradeTimeout = setTimeout(() => { let err = createImapError('Failed to upgrade connection in required time', 'UPGRADE_TIMEOUT'); settle(err); }, UPGRADE_TIMEOUT); /* c8 ignore stop */ this.upgrading = true; let tlsSocket; try { let onSecure = () => { try { /* c8 ignore start */ // race: connection closed during the TLS handshake window if (this.isClosed) { return settle(this.createNoConnectionError(false, { rejectedFrom: 'tlsUpgrade' })); } /* c8 ignore stop */ // TLS handshake complete. Reconnect the now-encrypted socket // to the IMAP parser stream and record the cipher details. this.secureConnection = true; this.streamer.secureConnection = true; tlsSocket.pipe(this.streamer); // Cloudflare Workers expose getCipher() but return null from it, so the // result is normalized to the documented `false` /* c8 ignore next */ // on Node an upgraded TLS socket always answers getCipher(), so the false fallback is unreachable this.tls = (typeof tlsSocket.getCipher === 'function' && tlsSocket.getCipher()) || false; if (this.tls) { this.tls.authorized = tlsSocket.authorized; this.log.info({ src: 'tls', msg: 'Established TLS session', cid: this.id, authorized: this.tls.authorized, /* c8 ignore next */ // cipher.standardName is present on modern Node, so the .name fallback rarely runs algo: this.tls.standardName || this.tls.name, version: this.tls.version }); } // The plain socket is now only the TLS transport: drop its superseded // inactivity timer so no armed timer is left behind without a listener. if (typeof socketPlain.setTimeout === 'function') { socketPlain.setTimeout(0); } // Install the normal socket handlers only now that the handshake // succeeded. Doing this during the handshake would leave both settle() and // the generic _socketError on the socket; a handshake 'error' would then fire // BOTH (EventEmitter clones its listener array on emit), causing a duplicate // error and a possible unhandled 'error' crash. Keeping settle() as the sole // listener until here guarantees a single error path for the upgrade. this.setSocketHandlers(); // Arm the inactivity watchdog on the socket that now carries the session. // Without this a STARTTLS-upgraded connection has no watchdog at all: the // timer was armed on the plain socket, while the timeout listener lives on // the TLS socket. this.configureSocket(this.socket); // settle() also removes the temporary handshake handlers settle(null, true); /* c8 ignore next 3 */ // defensive: the success callback body does not throw under normal operation } catch (ex) { this.emitError(ex); } }; // a transport without upgrade() never gets here, see the STARTTLS check above tlsSocket = this.options.transport ? this.options.transport.upgrade(socketPlain, this.transportOptions(), onSecure) : tls.connect(opts, onSecure); } catch (err) { // tls.connect() refused the upgrade before any handshake (an option the runtime // does not implement, a socket it can not wrap). Settled through the same path // as a handshake failure, so the upgrade state and its timer are cleared and // the error is marked as a TLS failure rather than escaping the executor. settle(err); return; } this.socket = tlsSocket; // Registered after tls.connect (the TLS socket now exists). This is the ONLY // error listener during the handshake window; the generic handlers are installed // by setSocketHandlers() inside the success callback above, so a handshake error // has a single error path. tlsSocket.on('error', settle); this.writeSocket = tlsSocket; }); if (upgraded) { // RFC 9051 section 6.2.1: once TLS is started the client MUST discard the // cached capabilities and reissue CAPABILITY, because everything learned // before the handshake was plaintext an active attacker could rewrite. // Unconditional on purpose: a server that stamps [CAPABILITY ...] on the // STARTTLS OK itself clears expectCapabilityUpdate, so keying the discard // on that flag would keep exactly the pre-TLS list an attacker controls - // the list that then picks the AUTH mechanism and answers LOGINDISABLED. this.clearCapabilities(); await this.run('CAPABILITY'); } return upgraded; } /** @internal */ async setAuthenticationState() { this.state = this.states.AUTHENTICATED; this.authenticated = true; if (this.expectCapabilityUpdate) { // update capabilities await this.run('CAPABILITY'); } } /** @internal */ async authenticate() { if (this.state === this.states.LOGOUT) { throw new AuthenticationFailure('Already logged out'); } if (this.state !== this.states.NOT_AUTHENTICATED) { // nothing to do here, usually happens with PREAUTH greeting return true; } if (!this.options.auth) { throw new AuthenticationFailure('Please configure the login'); } this.expectCapabilityUpdate = true; let loginMethod = (this.options.auth.loginMethod || '').toString().trim().toUpperCase(); if (!loginMethod && /\\|\//.test(this.options.auth.user)) { // Special override for MS Exchange when authenticating as some other user or non-email account loginMethod = 'LOGIN'; } if (this.options.auth.accessToken) { this.authenticated = await this.run('AUTHENTICATE', this.options.auth.user, { accessToken: this.options.auth.accessToken }); } else if (this.options.auth.pass) { if ((this.capabilities.has('AUTH=LOGIN') || this.capabilities.has('AUTH=PLAIN')) && loginMethod !== 'LOGIN') { this.authenticated = await this.run('AUTHENTICATE', this.options.auth.user, { password: this.options.auth.pass, loginMethod, authzid: this.options.auth.authzid }); } else { if (this.capabilities.has('LOGINDISABLED')) { throw new AuthenticationFailure('Login is disabled'); } this.authenticated = await this.run('LOGIN', this.options.auth.user, this.options.auth.pass); } } else { throw new AuthenticationFailure('No password configured'); } if (this.authenticated) { this.log.info({ src: 'auth', msg: 'User authenticated', cid: this.id, user: this.options.auth.user }); await this.setAuthenticationState(); return true; } throw new AuthenticationFailure('No matching authentication method'); } /** @internal */ beginSession() { clearTimer(this.greetingTimeout); this.greetingReceived = true; this.untaggedHandlers.OK = null; this.untaggedHandlers.PREAUTH = null; if (this.isClosed) { return; } // get out of current parsing "thread", so do not await for startSession this.startSession() .then(() => { if (typeof this.initialResolve === 'function') { let resolve = this.initialResolve; this.initialResolve = false; this.initialReject = false; return resolve(); } }) .catch(err => { // The transport goes with the failed attempt: the instance can not be reused, and // an open socket would only trip the inactivity watchdog later this.closeAfter(); if (typeof this.initialReject === 'function') { this.log.error({ err, cid: this.id }); clearTimer(this.greetingTimeout); let reject = this.initialReject; this.initialResolve = false; this.initialReject = false; return reject(err); } // connect() was already settled by whatever took the connection down (a socket // error or close rejected it first), so this failure is a consequence of that. // A second 'error' event for it would throw when no listener is attached. logConnectionError(this, 'Session setup failed', err); }); } /** @internal */ async initialOK(message) { this.greeting = getTextValues(message.attributes) .filter(entry => entry) .join(''); this.beginSession(); } /** @internal */ async initialPREAUTH() { if (this.isClosed) { return; } this.state = this.states.AUTHENTICATED; // documented contract for the `authenticated` property: `true` when the // connection was authenticated by a PREAUTH greeting (no credentials known) this.authenticated = true; this.beginSession(); } /** @internal */ async serverBye(parsed) { // Extract BYE reason from response for better error messages let reason = parsed && getTextValues(parsed.attributes) .map(val => val.trim()) .join(' '); this.byeReason = reason || 'Server closed connection'; this.untaggedHandlers.BYE = null; this.state = this.states.LOGOUT; // BYE means the server is about to close the connection (RFC 9051 7.1.5), so the socket // is not waited out: a BYE greeting kept open would leave connect() pending until the // greeting timeout, and an unsolicited BYE whose FIN never arrives would leave the // in-flight command and the queue behind it waiting for the socket watchdog. The BYE // that answers a LOGOUT is the exception: its tagged completion closes the connection. const answersLogout = !!this.currentRequest && this.currentRequest.command.toUpperCase() === 'LOGOUT'; if (!this.greetingReceived || !answersLogout) { this.closeAfter(); } } // Drops every capability-derived field together - the counterpart of // updateCapabilitiesFromRaw() below, which sets them together. rawCapabilities is // public surface external consumers read, so a discard (RFC 9051 6.2.1 requires // one after STARTTLS) that missed it would leave the stale list visible if the // re-fetch fails. /** @internal */ clearCapabilities() { this.capabilities.clear(); this.authCapabilities.clear(); this.rawCapabilities = null; } /** @internal */ updateCapabilitiesFromRaw(rawCapabilities) { this.rawCapabilities = rawCapabilities; this.capabilities = updateCapabilities(rawCapabilities); if (this.capabilities) { for (let [capa] of this.capabilities) { if (/^AUTH=/i.test(capa) && !this.authCapabilities.has(capa.toUpperCase())) { this.authCapabilities.set(capa.toUpperCase(), false); } } } if (this.expectCapabilityUpdate) { this.expectCapabilityUpdate = false; } } /** @internal */ async sectionCapability(section) { this.updateCapabilitiesFromRaw(section); } /** @internal */ async untaggedCapability(untagged) { this.updateCapabilitiesFromRaw(untagged.attributes); } /** @internal */ async untaggedExists(untagged) { if (!this.mailbox) { // mailbox closed, ignore return; } if (!untagged) { return; } // Not a usable count: anything but a bounded digit run. A digit run long enough // coerces to Infinity, which would corrupt mailbox state (resolveRange('*') would // compile to the literal "Infinity" and every range-based command would fail until // the next SELECT) let count = parseUintValue(untagged.command, MAX_UINT32_DIGITS); if (count === false) { return; } if (count === this.mailbox.exists) { // nothing changed? return; } // keep exists up to date let prevCount = this.mailbox.exists; this.mailbox.exists = count; emitSafe(this, 'exists', { path: this.mailbox.path, count, prevCount }); } // Reports one expunged message, either through the caller's expungeHandler or as an // 'expunge' event. Shared by the EXPUNGE and VANISHED paths so the two cannot drift. /** @internal */ async notifyExpunge(payload) { if (typeof this.options.expungeHandler !== 'function') { emitSafe(this, 'expunge', payload); return; } try { await this.options.expungeHandler(payload); } catch (err) { // The throw comes from the caller's own handler, not from this library this.log.error({ msg: 'Failed to notify expunge event', payload, err, cid: this.id }); } } /** @internal */ async untaggedExpunge(untagged) { if (!this.mailbox) { // mailbox closed, ignore return; } if (!untagged) { return; } // Same bound untaggedExists() applies: only a bounded decimal run is a usable sequence number let seq = parseUintValue(untagged.command, MAX_UINT32_DIGITS); if (seq && seq <= this.mailbox.exists) { this.mailbox.exists--; let payload = { path: this.mailbox.path, seq, vanished: false }; await this.notifyExpunge(payload); } } /** @internal */ async untaggedVanished(untagged, mailbox) { mailbox = mailbox || this.mailbox; if (!mailbox) { // mailbox closed, ignore return; } let tags = []; let uids = false; // A malformed VANISHED can carry no attributes at all, and one carrying only the // (EARLIER) tag leaves `uids` false - iterateRange() handles that and yields nothing if (!untagged.attributes || !untagged.attributes.length) { return; } if (untagged.attributes.length > 1 && Array.isArray(untagged.attributes[0])) { tags = getStringList(untagged.attributes[0]).map(value => value.toUpperCase()); untagged.attributes.shift(); } if (untagged.attributes[0] && typeof untagged.attributes[0].value === 'string') { uids = untagged.attributes[0].value; } let earlier = tags.includes('EARLIER'); // RFC 7162 section 3.2.10: unlike VANISHED (EARLIER), a plain VANISHED reports messages the // client knows about and decrements the message count like the same number of EXPUNGEs would if (!earlier) { mailbox.exists = Math.max(0, mailbox.exists - countRange(uids)); } // The set is walked one UID at a time rather than expanded first: it is the server's to // choose, and a single "1:16777216" is a few bytes on the wire but sixteen million array // entries expanded, for every such line the server cares to send for (let uid of iterateRange(uids)) { let payload = { path: mailbox.path, uid, vanished: true, earlier }; await this.notifyExpunge(payload); } } /** @internal */ async untaggedFetch(untagged, mailbox) { mailbox = mailbox || this.mailbox; if (!mailbox) { // mailbox closed, ignore return; } let message = await formatMessageResponse(untagged, mailbox, this.idHashAlgorithm, this); if (message.flags) { let updateEvent = { path: mailbox.path, seq: message.seq }; if (message.uid) { updateEvent.uid = message.uid; } if (message.modseq) { updateEvent.modseq = message.modseq; } updateEvent.flags = message.flags; if (message.flagColor) { updateEvent.flagColor = message.flagColor; } emitSafe(this, 'flags', updateEvent); } } /** @internal */ async ensureSelectedMailbox(path) { if (!path) { return false; } if (!this.mailbox || !comparePaths(this, this.mailbox.path, Array.isArray(path) ? normalizePath(this, path) : path)) { return await this.mailboxOpen(path); } return true; } // servername for tls.connect(), left out for an IP literal host (this.servername is false then): // Node treats a false value like a missing one, but Bun throws a TypeError for it /** @internal */ tlsServername() { return this.servername ? { servername: this.servername } : {}; } /** @internal */ transportOptions() { return Object.assign({ host: this.host, port: this.port, tls: this.options.tls }, this.tlsServername()); } // Normalizes a message range from various input formats into an IMAP-compatible // sequence string (e.g., "1:5,7,10:*"). Handles: numbers, "*", {all:true}, // {uid:value}, search query objects (resolved via SEARCH), and arrays of numbers. /** @internal */ async resolveRange(range, options) { let value = range; if (typeof value === 'number' || typeof value === 'bigint') { value = value.toString(); } // Replace "*" with the actual message count. Some servers reject bare "*" // in certain commands, and this also forces a sequence query (not UID). if (value === '*') { if (!this.mailbox || !this.mailbox.exists) { return false; } value = this.mailbox.exists.toString(); options.uid = false; // sequence query } if (value && typeof value === 'object' && !Array.isArray(value)) { if (value.all && Object.keys(value).length === 1) { value = '1:*'; } else if (value.uid && Object.keys(value).length === 1) { value = value.uid; options.uid = true; } else { // Arbitrary search query object: run SEARCH to resolve it into // a set of UIDs, then pack into a compact range string. options.uid = true; // force UIDs instead of sequence numbers value = await this.run('SEARCH', value, options); if (value && value.length) { value = packMessageRange(value); } } } if (Array.isArray(value)) { value = value.join(','); } if (!value) { return false; } // An empty mailbox has no message numbers: every sequence set, "1:*" included, would get a // BAD (RFC 9051 section 9, seq-number). UID sets may point past the end, so they are sent. if (!options.uid && this.mailbox && !this.mailbox.exists) { return false; } return value; } // The single definition of "the connection is not free". A held or queued mailbox lock, a // command in flight or queued, and an open download stream all mean a caller is // mid-sequence: starting IDLE there injects an IDLE/DONE round trip - or, with // `missingIdleCommand` set to SELECT or STATUS, a mailbox poll - between two of that // caller's own commands. Every one of those states ends by calling autoidle() again, so // declining while busy postpones IDLE, it never cancels it. /** @internal */ connectionBusy() { return !!(this.currentLock || this.locks.length || this.currentRequest || this.requestQueue.length || this._openDownloads); } /** @internal */ wakeIdleWaiters() { if (this._idleWaiters.length) { for (let { resolve } of this._idleWaiters.splice(0)) { resolve(); } } } // Timer process-liveness policy: connection establishment and greeting deadlines keep the // process alive, because a caller is waiting on connect() to settle. Background timers // (auto-IDLE, IDLE restart, fallback polling, throttle back-off, the held-lock diagnostic) are // unref'd, so an otherwise idle process is not held open by them. Every timer is still cleared // explicitly on close(). /** @internal */ autoidle() { // every place the connection may have become free calls this, so it is where waiting // idle({ whenFree: true }) calls re-check it, also with auto-IDLE turned off this.wakeIdleWaiters(); clearTimer(this.idleStartTimer); if (this.options.disableAutoIdle || !this.usable || this.state !== this.states.SELECTED) { return; } if (this.connectionBusy()) { return; } this.idleStartTimer = setTimeout(() => { // Re-checked at fire time: paths that take ownership of the connection clear this // timer, but the guard must not depend on every one of them doing so - a single // missed clearTimeout would inject IDLE between a caller's own commands. Declining // postpones rather than cancels: whatever made the connection busy calls autoidle() // again when it finishes. if (!this.usable || this.state !== this.states.SELECTED || this.connectionBusy()) { return; } this.idle().catch(err => logConnectionError(this, 'Auto-IDLE failed', err)); }, this.autoIdleDelay); unrefTimer(this.idleStartTimer); } // PUBLIC API METHODS /** * Initiates a connection against IMAP server. Throws if anything goes wrong. This is something you have to call before you can run any IMAP commands * * @throws Will throw an error if connection or authentication fails * @example * let client = new ImapFlow({...}); * await client.connect(); */ async connect() { if (this._connectCalled) { // Prevent re-using ImapFlow instances by allowing to call connect just once. throw createImapError('Can not re-use ImapFlow instance', 'InstanceReused'); } this._connectCalled = true; let closedError = () => this.createNoConnectionError(this.byeReason, { rejectedFrom: 'connect' }); // close() already ran, so there is no connection to set up and nothing would ever // settle a connect attempt started now. if (this.isClosed) { throw closedError(); } // One deadline for the whole attempt, started before anything is resolved or negotiated. // Proxy DNS and proxy negotiation used to run entirely outside the timer, so a stalled // proxy could hang far beyond the documented connectionTimeout. let deadline = new ConnectionDeadline(this.options.connectionTimeout); let connector = this.secureConnection ? tls : net; let opts = Object.assign({ host: this.host, port: this.port }, this.tlsServername(), this.options.tls || {}); this.untaggedHandlers.OK = (...args) => this.initialOK(...args); this.untaggedHandlers.BYE = (...args) => this.serverBye(...args); this.untaggedHandlers.PREAUTH = () => this.initialPREAUTH(); this.untaggedHandlers.CAPABILITY = (...args) => this.untaggedCapability(...args); this.sectionHandlers.CAPABILITY = (...args) => this.sectionCapability(...args); this.untaggedHandlers.EXISTS = (...args) => this.untaggedExists(...args); this.untaggedHandlers.EXPUNGE = (...args) => this.untaggedExpunge(...args); // these methods take an optional second argument, so make sure that some random IMAP tag is not used as the second argument this.untaggedHandlers.FETCH = untagged => this.untaggedFetch(untagged); this.untaggedHandlers.VANISHED = untagged => this.untaggedVanished(untagged); let transport = this.options.transport; if (transport && this.options.proxy) { throw createImapError('The proxy option can not be combined with a custom transport', 'UnsupportedTransportOption'); } let socket = false; if (this.options.proxy) { try { socket = await proxyConnection(this.log, this.options.proxy, this.host, this.port, { deadline }); if (!socket) { throw new Error('Failed to setup proxy connection'); } } catch (err) { // Logged here rather than relying on proxy-connection.ts, which only reports // failures from inside the two connect helpers. An unsupported scheme, a proxy URL // that will not parse and a deadline that expired before the connect started all // reject before any logging happens there, so this is the one place that sees // every way proxy setup can fail. this.log.error({ msg: 'Failed to setup proxy connection', err, cid: this.id }); if (err.code === 'CONNECT_TIMEOUT') { // The shared deadline expired during proxy setup. Report it as the documented // connection timeout rather than as a generic proxy failure. throw err; } let error = new Error('Failed to setup proxy connection'); error.code = err.code || 'ProxyError'; error._err = err; throw error; } // close() during proxy setup found no socket to destroy and no connect to reject, // so the tunnel that just opened is dropped here instead of being handed to a // closed client. if (this.isClosed) { socket.destroy(); throw closedError(); } } // Guarded: close() rejects a pending connect() synchronously. See guardedPromise(). let connectPromise = guardedPromise((resolve, reject) => { // Stored before the transport is up, so a close() that lands before onConnect // still rejects this attempt through closeConnectSteps(). this.initialResolve = resolve; this.initialReject = reject; // Whatever the proxy phase already used is gone from the budget this.connectTimeout = setTimeout(() => { let err = deadline.error(); this.log.error({ err, cid: this.id }); this.closeAfter(); reject(err); }, deadline.remaining()); let onConnect = () => { // close() may have run between the socket assignment and this callback (the // cleartext proxy path defers it): connect() is already rejected and the socket // gone, so arming the greeting timer now would only fire a stray GREETING_TIMEOUT if (this.isClosed || !this.socket) { return; } try { clearTimer(this.connectTimeout); this.connectedAt = Date.now(); // ImapFlow now owns the socket; drop the proxy's early error handler // (its "before connection setup" message no longer applies). detachEarlyErrorHandler(socket); this.configureSocket(this.socket); this.greetingTimeout = setTimeout(() => { let err = createImapError( /* c8 ignore next */ // the greeting-timeout test uses a plaintext socket; the secure-socket branch of this hint is not separately exercised `Failed to receive greeting from server in required time${!this.secureConnection ? '. Maybe should use TLS?' : ''}`, 'GREETING_TIMEOUT', { details: { /* c8 ignore next */ // firing the timeout with the default (large) value would hang the suite, so only the explicit-option path is tested greetingTimeout: this.options.greetingTimeout || GREETING_TIMEOUT } }); this.log.error({ err, cid: this.id }); this.closeAfter(); reject(err); }, this.options.greetingTimeout || GREETING_TIMEOUT); const connected = this.socket; this.tls = (typeof connected.getCipher === 'function' && connected.getCipher()) || false; let logInfo = { src: 'connection', msg: `Established ${this.tls ? 'secure ' : ''}TCP connection`, cid: this.id, secure: !!this.tls, host: this.host, servername: this.servername, port: connected.remotePort, address: connected.remoteAddress, localAddress: connected.localAddress, localPort: connected.localPort }; if (this.tls) { logInfo.authorized = this.tls.authorized = connected.authorized; /* c8 ignore next */ // cipher.standardName is present on modern Node, so the .name fallback rarely runs logInfo.algo = this.tls.standardName || this.tls.name; logInfo.version = this.tls.version; } this.log.info(logInfo); this.setSocketHandlers(); this.setEventHandlers(); connected.pipe(this.streamer); /* c8 ignore next 4 */ // defensive: the onConnect setup body does not throw under normal operation } catch (ex) { // connect failed reject(ex); } }; if (socket) { // socket is already established via proxy if (this.secureConnection) { // TLS socket requires a handshake opts.socket = socket; this.socket = connector.connect(opts, onConnect); } else { // cleartext socket is already usable this.socket = socket; setImmediate(onConnect); } } else if (transport) { try { this.socket = transport.connect({ ...this.transportOptions(), secure: this.secureConnection }, onConnect); } catch (err) { // refused up front: the connection deadline must not stay armed clearTimer(this.connectTimeout); this.closeAfter(); this.log.error({ err, cid: this.id }); reject(err); return; } } else { this.socket = connector.connect(opts, onConnect); } this.writeSocket = this.socket; // Store connection error handler for cleanup this._connectErrorHandler = (err) => { clearTimer(this.connectTimeout); clearTimer(this.greetingTimeout); this.closeAfter(); this.log.error({ err, cid: this.id }); reject(err); }; this.socket.on('error', this._connectErrorHandler); }); await connectPromise; } /** * Graceful connection close by sending logout command to server. TCP connection is closed once command is finished. * * @example * let client = new ImapFlow({...}); * await client.connect(); * ... * await client.logout(); */ async logout() { return await this.run('LOGOUT'); } /** * Close the TCP connection. * Unlike `close()`, return immediately from this function, allowing the * caller function to proceed, and run `close()` function afterwards. */ closeAfter() { setImmediate(() => this.close()); } // Connection-scoped wrapper around the shared stamping helper; see buildConnectionError(). /** @internal */ createConnectionError(code, message, meta) { return buildConnectionError(this.id, code, message, meta); } // The standard "connection not available" error, optionally annotated with the server's BYE // reason. Single source of truth so every NoConnection rejection is consistent. /** @internal */ createNoConnectionError(byeReason, meta) { const error = this.createConnectionError('NoConnection', 'Connection not available', meta); if (byeReason) { error.reason = byeReason; } return error; } // What a message command called without a selected mailbox rejects with: NoMailboxSelected, // or NoConnection once the connection is gone, as that is why the mailbox is missing then /** @internal */ createNoMailboxError(command) { if (!this.usable) { return this.createNoConnectionError(this.byeReason, { rejectedFrom: 'noMailbox', command }); } return createImapError('No mailbox is selected', 'NoMailboxSelected', { command }); } /** * Closes TCP connection without notifying the server. * * @example * let client = new ImapFlow({...}); * await client.connect(); * ... * client.close(); */ close() { try { this.closeTimers(); this.usable = false; // close() takes over ownership of the idling state: dropping the session token means a // poll or IDLE that unwinds after this point sees that it no longer owns the flag and // leaves it alone (see claimIdling() in commands/idle.ts). this._idleSession = null; // a later close() finds idling already cleared and keeps what the first one saw this.idlingAtClose = this.idlingAtClose || this.idling; this.idling = false; this.closeConnectSteps(); if (typeof this.preCheck === 'function') { // Taken off the connection first: breaking IDLE writes DONE, a write on a dead // transport calls close(), and close() must not find this function here and // re-enter it. The rejection this sees is almost always the NoConnection close() // is about to raise itself. let preCheck = this.preCheck; this.preCheck = false; preCheck().catch(err => logConnectionError(this, 'Failed to break IDLE while closing', err)); } // Session-only public state must not survive the connection it describes: callers read // these properties in reconnect logic and would otherwise mistake cached objects for // live server state. Cleared during the first close only, so repeated close() calls // stay idempotent and cannot emit an event twice. // `byeReason` is deliberately kept: it explains why the session ended. // // `authenticated` is kept for a verifyOnly connection, where it is the result rather // than live state. That mode authenticates, optionally lists, and logs out before // connect() resolves, so clearing it here left every caller reading `false` off a // connection that had just authenticated successfully - there is no later moment at // which the answer could be read, and such a client is never reconnected. let closedMailbox = false; if (!this.isClosed) { closedMailbox = this.mailbox; this.mailbox = false; this.currentSelectCommand = false; if (!this.options.verifyOnly) { this.authenticated = false; } this.preCheck = false; } this.closeRequests(); this.closeLocks(); if (this._idleWaiters.length) { let waiterError = this.createNoConnectionError(this.byeReason, { rejectedFrom: 'idleWait', command: 'IDLE' }); for (let { reject } of this._idleWaiters.splice(0)) { reject(waiterError); } } this.closeStreams(); // clear socket handlers this.clearSocketHandlers(); // clear cached data this.folders.clear(); this.requestTagMap.clear(); this.state = this.states.LOGOUT; if (this.isClosed) { return; } // Set before teardown so a socket event that re-enters close() during destruction // cannot run this block a second time. this.isClosed = true; this.closeSockets(); this.log.debug({ msg: 'Connection closed', cid: this.id, ...(this._unknownTagCount ? { unknownTagCount: this._unknownTagCount } : {}) }); // A mailbox that was still selected is now closed, so the transition is reported once, // whether the session ended with a clean logout or a lost transport. Emitted before // 'close' and only from the first close(), so no consumer sees it twice. if (closedMailbox) { emitSafe(this, 'mailboxClose', closedMailbox); } this.emit('close'); } catch (ex) { // close failed this.log.error({ err: ex, cid: this.id }); } } /** * Part of close(): clears the pending timers and aborts every throttle back-off. * * @internal */ closeTimers() { // clear pending timers clearTimer(this.idleStartTimer); clearTimer(this.upgradeTimeout); clearTimer(this.connectTimeout); clearTimer(this.greetingTimeout); // Abort every in-flight throttle back-off so each waiter unblocks and its request is // settled promptly rather than after the full delay. for (let entry of this._throttleWaits) { clearTimer(entry.timer); entry.resolve(true); } this._throttleWaits.clear(); } /** * Part of close(): settles an in-flight STARTTLS upgrade and a pending connect(). * * @internal */ closeConnectSteps() { // An in-flight STARTTLS upgrade has to be settled through its own single settlement // path, otherwise the upgrade promise (and the session it belongs to) stays pending // for the lifetime of the process. if (typeof this._upgradeReject === 'function') { let reject = this._upgradeReject; this._upgradeReject = null; reject(this.createNoConnectionError(false, { rejectedFrom: 'upgrade' })); } // A verifyOnly session closes itself with LOGOUT and startSession() settles connect() // with the outcome, so the close is not a failure there. Before the greeting nothing // else can settle it (BYE greeting, FIN, close() while the transport is still coming up). if (typeof this.initialReject === 'function' && (!this.options.verifyOnly || !this.greetingReceived)) { clearTimer(this.greetingTimeout); let reject = this.initialReject; this.initialResolve = false; this.initialReject = false; let err = createImapError('Unexpected close', /* c8 ignore next */ // closing a pending connect over an already-secure socket (the TLS branch) is not separately exercised this.secureConnection ? 'ClosedAfterConnectTLS' : 'ClosedAfterConnectText'); // Surface the server's BYE reason (e.g. "Too many connections") when the // connection was closed by an untagged BYE, so the caller sees why. if (this.byeReason) { err.reason = this.byeReason; } // Synchronous rejection is safe: connectPromise was built by guardedPromise(), // so the rejection is already observed. close() is synchronous, so all cleanup // completes before any microtask rejection handler runs. reject(err); } } /** * Part of close(): rejects the command in flight and every queued command. * * @internal */ closeRequests() { // Collect all pending requests to reject let pendingRequests = []; this.commandParts = []; // reject command that is currently processed if (this.currentRequest && this.requestTagMap.has(this.currentRequest.tag)) { let tag = this.currentRequest.tag; let request = this.requestTagMap.get(tag); if (request) { this.requestTagMap.delete(tag); pendingRequests.push(request); } this.currentRequest = false; } // reject all other pending commands while (this.requestQueue.length) { let req = this.requestQueue.shift(); if (req && this.requestTagMap.has(req.tag)) { let request = this.requestTagMap.get(req.tag); if (request) { this.requestTagMap.delete(req.tag); pendingRequests.push(request); } } } // Reject pending requests and locks synchronously. Every promise rejected here was // built by guardedPromise(), so its rejection is already observed and cannot trigger // unhandledRejection. close() is synchronous, so all remaining cleanup runs before // any microtask rejection handler fires. // // The error travels on, though, through await chains and .then() links that // guardedPromise() knows nothing about. Read a crash stack ending here as "this is // the value that escaped", never as "this is the promise that escaped". for (let request of pendingRequests) { request.reject(this.createNoConnectionError(this.byeReason, { rejectedFrom: 'pendingRequest', command: request.command })); } } /** * Part of close(): drops the held lock and rejects every pending lock request. * * @internal */ closeLocks() { // Clear current lock - holder will see errors when they try operations. // Also clear the held-lock diagnostic timer so it doesn't fire post-close. if (this.currentLock && this.currentLock.heldWarnTimer) { clearTimer(this.currentLock.heldWarnTimer); this.currentLock.heldWarnTimer = null; } this.currentLock = false; if (this.locks && this.locks.length) { let pendingLocks = this.locks.splice(0); // Take all locks and clear the array for (let lock of pendingLocks) { if (lock.acquireTimer) { clearTimer(lock.acquireTimer); lock.acquireTimer = null; } if (typeof lock.reject === 'function') { lock.reject(this.createNoConnectionError(this.byeReason, { rejectedFrom: 'mailboxLock', path: lock.path })); } } } } /** * Part of close(): destroys the compression streams and the response streamer. * * @internal */ closeStreams() { // cleanup compression streams if they exist if (this._inflate) { try { this._inflate.unpipe(); this._inflate.destroy(); this._inflate = null; } catch (err) { this.log.error({ err, msg: 'Failed to destroy inflate stream', cid: this.id }); } } if (this._deflate) { try { this._deflate.unpipe(); this._deflate.destroy(); this._deflate = null; } catch (err) { this.log.error({ err, msg: 'Failed to destroy deflate stream', cid: this.id }); } } // cleanup streamer if (this.streamer) { try { // remove our listeners explicitly by reference if (this.socketReadable) { this.streamer.removeListener('readable', this.socketReadable); } if (this._streamerErrorHandler) { this.streamer.removeListener('error', this._streamerErrorHandler); } if (!this.streamer.destroyed) { this.streamer.destroy(); } } catch (err) { this.log.error({ err, msg: 'Failed to cleanup streamer', cid: this.id }); } } } /** * Part of close(): destroys the sockets once and drops every socket and handler reference. * * @internal */ closeSockets() { // Socket teardown, in one documented order. Each stream owns and reports its own // lifecycle, so each is destroyed exactly once: // 1. the compression PassThrough (writeSocket), if compression replaced it // 2. the raw socket, which is also writeSocket when compression is not active // The compression streams themselves were destroyed in closeStreams(). if (this.writeSocket && this.writeSocket !== this.socket && !this.writeSocket.destroyed) { try { this.writeSocket.destroy(); } catch (err) { this.log.error({ err, cid: this.id }); } } if (this.socket && !this.socket.destroyed) { try { this.socket.destroy(); } catch (err) { this.log.error({ err, cid: this.id }); } } // Null out all socket and handler references so the GC can collect // them even if the ImapFlow instance itself is still referenced. this.socket = null; this.writeSocket = null; // closeStreams() leaves these set when destroying them failed this._inflate = null; this._deflate = null; this._streamerErrorHandler = null; this._connectErrorHandler = null; this._socketError = null; this._socketClose = null; this._socketEnd = null; this._socketTimeout = null; } /** * Returns current quota * * @param path Optional mailbox path if you want to check quota for specific folder. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required. * @returns Quota information or `false` if QUOTA extension is not supported or requested path does not exist * * @example * let quota = await client.getQuota(); * console.log(quota.storage.used, quota.storage.limit) */ async getQuota(path) { path = path || 'INBOX'; return await this.run('QUOTA', path); } /** * Lists available mailboxes as an Array * * @param options defines additional listing options * @returns An array of ListResponse objects * * @example * let list = await client.list(); * list.forEach(mailbox=>console.log(mailbox.path)); */ async list(options) { options = options || {}; let folders = await this.run('LIST', '', '*', options); this.folders = new Map(folders.map(folder => [folder.path, folder])); return folders; } /** * Lists available mailboxes as a tree structured object * * @param options defines additional listing options * @returns Tree structured object * * @example * let tree = await client.listTree(); * tree.folders.forEach(mailbox=>console.log(mailbox.path)); */ async listTree(options) { options = options || {}; let folders = await this.run('LIST', '', '*', options); this.folders = new Map(folders.map(folder => [folder.path, folder])); return getFolderTree(folders); } /** * Performs a no-op call against server */ async noop() { await this.run('NOOP'); } /** * Creates a new mailbox folder and sets up subscription for the created mailbox. Throws on error. * * @param path Full mailbox path. Unicode is allowed. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required. * @returns Mailbox info * @throws Will throw an error if mailbox can not be created * * @example * let info = await client.mailboxCreate(['parent', 'child']); * console.log(info.path); * // "INBOX.parent.child" // assumes "INBOX." as namespace prefix and "." as delimiter */ async mailboxCreate(path) { return await this.run('CREATE', path); } /** * Renames a mailbox. Throws on error. * * @param path Path for the mailbox to rename. Unicode is allowed. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required. * @param newPath New path for the mailbox * @returns Mailbox info * @throws Will throw an error if mailbox does not exist or can not be renamed * * @example * let info = await client.mailboxRename('parent.child', 'Important stuff'); * console.log(info.newPath); * // "INBOX.Important stuff" // assumes "INBOX." as namespace prefix */ async mailboxRename(path, newPath) { return await this.run('RENAME', path, newPath); } /** * Deletes a mailbox. Throws on error. * * @param path Path for the mailbox to delete. Unicode is allowed. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required. * @returns Mailbox info * @throws Will throw an error if mailbox does not exist or can not be deleted * * @example * let info = await client.mailboxDelete('Important stuff'); * console.log(info.path); * // "INBOX.Important stuff" // assumes "INBOX." as namespace prefix */ async mailboxDelete(path) { return await this.run('DELETE', path); } /** * Subscribes to a mailbox * * @param path Path for the mailbox to subscribe to. Unicode is allowed. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required. * @returns `true` if subscription operation succeeded, `false` otherwise * * @example * await client.mailboxSubscribe('Important stuff'); */ async mailboxSubscribe(path) { return await this.run('SUBSCRIBE', path); } /** * Unsubscribes from a mailbox * * @param path **Path for the mailbox** to unsubscribe from. Unicode is allowed. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required. * @returns `true` if unsubscription operation succeeded, `false` otherwise * * @example * await client.mailboxUnsubscribe('Important stuff'); */ async mailboxUnsubscribe(path) { return await this.run('UNSUBSCRIBE', path); } /** * Opens a mailbox to access messages. You can perform message operations only against an opened mailbox. * Using {@link ImapFlow#getMailboxLock} instead of `mailboxOpen()` is preferred. Both do the same thing * but next `getMailboxLock()` call is not executed until previous one is released. * * @param path **Path for the mailbox** to open * @param options optional options * @returns Mailbox info * @throws Will throw an error if mailbox does not exist or can not be opened * * @example * let mailbox = await client.mailboxOpen('Important stuff'); * console.log(mailbox.exists); * // 125 */ async mailboxOpen(path, options) { try { return await this.run('SELECT', path, options); } catch (err) { if (err.responseStatus === 'NO') { // SELECT failed with NO: verify whether the mailbox exists at all by running // LIST. This sets mailboxMissing on the error so the caller can distinguish // "doesn't exist" from other failures, for getMailboxLock() callers as well. try { let normalized = normalizePath(this, path); let folders = await this.run('LIST', '', normalized, { listOnly: true }); // The path is the LIST pattern, so a % or * in a name also matches other // mailboxes, and an entry marked \NonExistent (RFC 5258) does not exist either let exists = Array.isArray(folders) && folders.some(entry => comparePaths(this, entry.path, normalized) && ![...(entry.flags || [])].some(flag => typeof flag === 'string' && flag.toUpperCase() === '\\NONEXISTENT')); if (!exists) { err.mailboxMissing = true; } } catch (E) { this.log.trace({ msg: 'Failed to verify failed mailbox', path, err: E }); } } throw err; } } /** * Closes a previously opened mailbox * * @returns Did the operation succeed or not * * @example * let mailbox = await client.mailboxOpen('INBOX'); * await client.mailboxClose(); */ async mailboxClose() { return await this.run('CLOSE'); } /** * Requests the status of the indicated mailbox. Only requested status values will be returned. * * @param path mailbox path to check for (unicode string). If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required. * @param query defines requested status items * @returns status of the indicated mailbox, or `false` if the server rejected the request * * @example * let status = await client.status('INBOX', {unseen: true}); * console.log(status.unseen); * // 123 */ async status(path, query) { return await this.run('STATUS', path, query); } /** * Starts listening for new or deleted messages from the currently opened mailbox. Only required if `disableAutoIdle` is set to `true` * otherwise IDLE is started by default on connection inactivity. NB! If `idle()` is called manually then it does not * return until IDLE is finished which means you would have to call some other command out of scope. * * Unlike auto-IDLE, a direct call does not wait for the connection to be free: a mailbox lock * holder may idle inside its own lock, and idle() can not tell it from another caller. Code that * holds no lock passes `{ whenFree: true }` to wait for the connection to be free first. * * @param options IDLE options * @returns `false` if IDLE failed, `undefined` otherwise * * @example * let mailbox = await client.mailboxOpen('INBOX'); * * await client.idle(); */ async idle(options) { // Re-checked on every wake, as another IDLE may have started meanwhile while (options?.whenFree && !this.idling && this.usable && this.connectionBusy()) { await new Promise((resolve, reject) => { this._idleWaiters.push({ resolve, reject }); }); } if (!this.idling) { return await this.run('IDLE', this.maxIdleTime); } } /** * Sets flags for a message or message range * * @param range Range to filter the messages * @param flags Array of flags to set. Only flags that are permitted to set are used, other flags are ignored. An empty array clears every flag, but a non-empty array with nothing usable in it is refused rather than clearing the message * @param options Store options * @returns Did the operation succeed or not * * @example * let mailbox = await client.mailboxOpen('INBOX'); * // mark all unseen messages as seen (and remove other flags) * await client.messageFlagsSet({seen: false}, ['\Seen]); */ async messageFlagsSet(range, flags, options) { options = options || {}; let resolved = await this.resolveRange(range, options); if (!resolved) { return false; } let queryOpts = Object.assign({ operation: 'set' }, options); return await this.run('STORE', resolved, flags, queryOpts); } /** * Adds flags for a message or message range * * @param range Range to filter the messages * @param flags Array of flags to set. Only flags that are permitted to set are used, other flags are ignored * @param options Store options * @returns Did the operation succeed or not * * @example * let mailbox = await client.mailboxOpen('INBOX'); * // mark all unseen messages as seen (and keep other flags as is) * await client.messageFlagsAdd({seen: false}, ['\Seen]); */ async messageFlagsAdd(range, flags, options) { options = options || {}; let resolved = await this.resolveRange(range, options); if (!resolved) { return false; } let queryOpts = Object.assign({ operation: 'add' }, options); return await this.run('STORE', resolved, flags, queryOpts); } /** * Remove specific flags from a message or message range * * @param range Range to filter the messages * @param flags Array of flags to remove. Only flags that are permitted to set are used, other flags are ignored * @param options Store options * @returns Did the operation succeed or not * * @example * let mailbox = await client.mailboxOpen('INBOX'); * // mark all seen messages as unseen by removing \\Seen flag * await client.messageFlagsRemove({seen: true}, ['\Seen]); */ async messageFlagsRemove(range, flags, options) { options = options || {}; let resolved = await this.resolveRange(range, options); if (!resolved) { return false; } let queryOpts = Object.assign({ operation: 'remove' }, options); return await this.run('STORE', resolved, flags, queryOpts); } /** * Sets a colored flag for an email. Only supported by mail clients like Apple Mail * * @param range Range to filter the messages * @param color The color to set. One of 'red', 'orange', 'yellow', 'green', 'blue', 'purple', and 'grey' * @param options Store options * @returns Did the operation succeed or not * * @example * let mailbox = await client.mailboxOpen('INBOX'); * // add a purple flag for all emails * await client.setFlagColor('1:*', 'Purple'); */ async setFlagColor(range, color, options) { options = options || {}; let resolved = await this.resolveRange(range, options); if (!resolved) { return false; } let flagChanges = getColorFlags(color); if (!flagChanges) { return false; } let addResults; let removeResults; if (flagChanges.add && flagChanges.add.length) { let queryOpts = Object.assign({ operation: 'add' }, options, { useLabels: false, // override if set // prevent triggering a premature Flags change notification silent: flagChanges.remove && flagChanges.remove.length }); addResults = await this.run('STORE', resolved, flagChanges.add, queryOpts); } if (flagChanges.remove && flagChanges.remove.length) { let queryOpts = Object.assign({ operation: 'remove' }, options, { useLabels: false } // override if set ); removeResults = await this.run('STORE', resolved, flagChanges.remove, queryOpts); } return addResults || removeResults || false; } /** * Delete messages from the currently opened mailbox. Method does not indicate info about deleted messages, * instead you should be using the `expunge` event for this * * @param range Range to filter the messages * @param options Range options * @returns Did the operation succeed or not * * @example * let mailbox = await client.mailboxOpen('INBOX'); * // delete all seen messages * await client.messageDelete({seen: true}); */ async messageDelete(range, options) { options = options || {}; let resolved = await this.resolveRange(range, options); if (!resolved) { return false; } return await this.run('EXPUNGE', resolved, options); } /** * Appends a new message to a mailbox * * @param path Mailbox path to upload the message to (unicode string). If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required. * @param content RFC822 formatted email message, as a Buffer, Uint8Array or string * @param flags an array of flags to be set for the uploaded message * @param idate internal date to be set for the message * @returns info about uploaded message * * @example * await client.append('INBOX', rawMessageBuffer, ['\\Seen'], new Date(2000, 1, 1)); */ async append(path, content, flags, idate) { return (await this.run('APPEND', path, content, flags, idate)) || false; } /** * Copies messages from current mailbox to destination mailbox * * @param range Range of messages to copy * @param destination Mailbox path to copy the messages to. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required. * @param options Range options * @returns info about copies messages * * @example * await client.mailboxOpen('INBOX'); * // copy all messages to a mailbox called "Backup" (must exist) * let result = await client.messageCopy('1:*', 'Backup'); * console.log('Copied %s messages', result.uidMap.size); */ async messageCopy(range, destination, options) { options = options || {}; let resolved = await this.resolveRange(range, options); if (!resolved) { return false; } return await this.run('COPY', resolved, destination, options); } /** * Moves messages from current mailbox to destination mailbox * * @param range Range of messages to move * @param destination Mailbox path to move the messages to. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required. * @param options Range options * @returns info about moved messages * * @example * await client.mailboxOpen('INBOX'); * // move all messages to a mailbox called "Trash" (must exist) * let result = await client.messageMove('1:*', 'Trash'); * console.log('Moved %s messages', result.uidMap.size); */ async messageMove(range, destination, options) { options = options || {}; let resolved = await this.resolveRange(range, options); if (!resolved) { return false; } return await this.run('MOVE', resolved, destination, options); } async search(query, options) { if (!this.mailbox) { // no mailbox selected, nothing to do return; } const result = (await this.run('SEARCH', query, options)) || false; // When returnOptions was requested but server lacked ESEARCH capability, // search.ts returns a plain number[]. Derive ESearchResult client-side. if (options && options.returnOptions && Array.isArray(result)) { const arr = result; // Normalize to uppercase so callers can use mixed-case strings like 'count' const normalizedOptions = options.returnOptions.map(o => (typeof o === 'string' ? o.toUpperCase() : o)); const esearch = {}; if (normalizedOptions.includes('COUNT')) { esearch.count = arr.length; } if (normalizedOptions.includes('MIN') && arr.length) { esearch.min = arr[0]; // already sorted ascending by search.ts } if (normalizedOptions.includes('MAX') && arr.length) { esearch.max = arr[arr.length - 1]; } if (normalizedOptions.includes('ALL') && arr.length) { esearch.all = packMessageRange(arr); } // PARTIAL cannot be derived client-side, omit it. // When returnOptions contains only { partial: ... } items and the server // lacks ESEARCH, PARTIAL cannot be derived client-side. Return the raw // number[] so the caller has actionable data. Note: this is an edge case, // callers targeting no-ESEARCH servers should avoid requesting PARTIAL // without COUNT or ALL. if (Object.keys(esearch).length === 0) { return result; } return esearch; } return result; } /** * Fetch messages from the currently opened mailbox * * @param range Range of messages to fetch * @param query Fetch query * @param options Fetch options * @yields Message data object * @throws An error with the code `NoMailboxSelected` when no mailbox is selected (for example * after `mailboxClose()` or a failed `mailboxOpen()`), or `NoConnection` once the connection * is gone. Earlier releases yielded nothing in that case, as for an empty mailbox * * @example * let mailbox = await client.mailboxOpen('INBOX'); * // fetch UID for all messages in a mailbox * for await (let msg of client.fetch('1:*', {uid: true})){ * console.log(msg.uid); * // NB! You can not run any IMAP commands in this loop * // otherwise you will end up in a deadloop * } */ async *fetch(range, query, options) { options = options || {}; if (!this.mailbox) { // an empty result would look like an empty mailbox, for example right after close() throw this.createNoMailboxError('FETCH'); } let resolved = await this.resolveRange(range, options); if (!resolved) { return false; } // Push/pull coordination for the async generator pattern: // The FETCH command handler pushes results into rowQueue via onUntaggedFetch. // The generator consumer pulls via getNext(). The `push` callback bridges the // two: when the consumer is waiting and the queue is empty, `push` is set to // a function that wakes up the consumer when new data arrives. let finished = false; let aborted = false; let push = false; let rowQueue = []; let getNext = () => new Promise((resolve, reject) => { let check = () => { if (rowQueue.length) { let entry = rowQueue.shift(); if (entry.err) { return reject(entry.err); } return resolve(entry.value); } if (finished) { return resolve(null); } // No data available yet; register a wakeup callback push = () => { push = false; check(); }; }; check(); }); // Fire-and-forget the FETCH command. It runs in the background while // the generator yields results. Each untagged FETCH response is paired // with a `next` callback that acts as backpressure: the FETCH handler // won't process the next response until the consumer calls next(). this.run('FETCH', resolved, query, { uid: !!options.uid, binary: options.binary, changedSince: options.changedSince, onUntaggedFetch: (untagged, next) => { if (aborted) { next(); return; } rowQueue.push({ value: { response: untagged, next } }); if (typeof push === 'function') { push(); } } }) .then(() => { finished = true; if (typeof push === 'function') { push(); } }) .catch(err => { rowQueue.push({ err }); if (typeof push === 'function') { push(); } }); let lastRes = null; try { let res; while ((res = await getNext())) { lastRes = res; if (this.isClosed || !this.socket || this.socket.destroyed) { throw this.createConnectionError('EConnectionClosed', 'Connection closed', { rejectedFrom: 'fetchStream', command: 'FETCH' }); } yield res.response; // Signal the FETCH handler to process the next untagged response res.next(); lastRes = null; } } finally { aborted = true; // Release backpressure for the item that was yielded but whose // next() was not yet called (happens on break/return/throw) if (lastRes && typeof lastRes.next === 'function') { lastRes.next(); } while (rowQueue.length) { let entry = rowQueue.shift(); if (entry.value && typeof entry.value.next === 'function') { entry.value.next(); } } } } /** * Fetch messages from the currently opened mailbox. * * This method will fetch all messages before resolving the promise, unlike .fetch(), which * is an async generator. Do not use large ranges like 1:*, as this might exhaust all available * memory if the mailbox contains a large number of emails. * @param range Range of messages to fetch * @param query Fetch query * @param options Fetch options * @returns Array of Message data object * @throws An error with the code `NoMailboxSelected` when no mailbox is selected, see fetch() * * @example * let mailbox = await client.mailboxOpen('INBOX'); * // fetch UID for all messages in a mailbox * const messages = await client.fetchAll('1:*', {uid: true}); * for (let msg of messages){ * console.log(msg.uid); * } */ async fetchAll(range, query, options) { const results = []; const generator = this.fetch(range, query, options); for await (const message of generator) { results.push(message); } return results; } /** * Fetch a single message from the currently opened mailbox * * @param seq Single UID or sequence number of the message to fetch for * @param query Fetch query * @param options Fetch options * @returns Message data object, `false` when the message was not found * @throws An error with the code `NoMailboxSelected` when no mailbox is selected, see fetch(). * Earlier releases resolved `undefined` then * * @example * let mailbox = await client.mailboxOpen('INBOX'); * // fetch UID for the last email in the selected mailbox * let lastMsg = await client.fetchOne('*', {uid: true}) * console.log(lastMsg.uid); */ async fetchOne(seq, query, options) { if (!this.mailbox) { // undefined used to be returned here, indistinguishable from a missing message for a // caller that only tests the result throw this.createNoMailboxError('FETCH'); } if (seq === '*') { if (!this.mailbox.exists) { return false; } seq = this.mailbox.exists.toString(); options = Object.assign({}, options || {}, { uid: false }); // force into a sequence query } let response = await this.run('FETCH', (seq || '').toString(), query, options); if (!response || !response.list || !response.list.length) { return false; } // Every FETCH row that arrived during the command is in the list, also unsolicited ones // for other messages or with only a flag change, and a server may split the answer for // one message over several rows. Taking the first row returned a flag update instead // of the requested data, which ended a download after its first chunk (issue #426). let rows = response.list; let requested = parseUintValue(String(seq), MAX_UINT32_DIGITS); // a range: the first message of the answer, as before let target = requested === false ? rows[0] : rows.find(row => (options && options.uid ? row.uid : row.seq) === requested); if (!target) { return false; } // rows of the same message, without the ones a malformed answer gives no usable // sequence number or another UID let { seq: targetSeq, uid: targetUid } = target; return targetSeq ? mergeFetchRows(rows.filter(row => row.seq === targetSeq && (!row.uid || !targetUid || row.uid === targetUid))) : target; } /** * Download either full rfc822 formatted message or a specific bodystructure part as a Stream. * Bodystructure parts are decoded so the resulting stream is a binary file. Text content * is automatically converted to UTF-8 charset. * * @param range UID or sequence number for the message to fetch * @param part If not set then downloads entire rfc822 formatted message, otherwise downloads specific bodystructure part * @param options Download options * @returns Download data object. Resolves with an empty object when no mailbox is selected or the message or part was not found * * @example * let mailbox = await client.mailboxOpen('INBOX'); * // download body part nr '1.2' from latest message * let download = await client.download('*', '1.2'); * if (download.content) { * // pipeline() rejects when the download fails, a plain .pipe() would not report it * await stream.promises.pipeline(download.content, fs.createWriteStream(download.meta.filename)); * } */ async download(range, part, options) { return await downloadMessage(this, range, part, options); } /** * Fetch multiple attachments as Buffer values * * @param range UID or sequence number for the message to fetch * @param parts A list of bodystructure parts * @param options Download options * @returns Download data object, keyed by part * * @example * let mailbox = await client.mailboxOpen('INBOX'); * // download body parts '2', and '3' from all messages in the selected mailbox * let response = await client.downloadMany('*', ['2', '3']); * process.stdout.write(response[2].content) * process.stdout.write(response[3].content) */ async downloadMany(range, parts, options) { return await downloadMessageParts(this, range, parts, options); } /** @internal */ async run(command, ...args) { command = command.toUpperCase(); if (!this.commands.has(command)) { return false; } if (!this.socket || this.socket.destroyed) { throw this.createNoConnectionError(false, { rejectedFrom: 'noSocket', command }); } clearTimer(this.idleStartTimer); try { // The preCheck (breaking an active IDLE) sits inside the try on purpose: the // clearTimeout above is unconditional, so every exit - a failed command or a // preCheck that rejects - must still reach the finally, or auto-IDLE would stay // disarmed on an otherwise healthy connection until some later command succeeded. if (typeof this.preCheck === 'function') { await this.preCheck(); } return await this.runInternal(command, ...args); } finally { // Re-arm auto-IDLE after every command, IDLE included. autoidle() clears any prior // timer and declines while the connection is busy or not SELECTED, so calling it // unconditionally is safe and is the single place the invariant lives. IDLE was once // carved out here on the theory that a command which broke it re-arms on its own way // out - but that only holds when a command broke it. When an IDLE or poll session ends // on its own (the server refused IDLE, ended it unsolicited, or a poll failed) this is // the only thing that re-arms it; without it such a connection would go dark until the // socket watchdog tore it down. When a command really did break IDLE, that command is // still in flight at this point, so autoidle() declines here and re-arms once it ends. this.autoidle(); } } /** * Dispatches a command without the IDLE handshake that `run()` performs. * * Used by callers that already own the connection's idle state - fallback polling issues its * commands through here, because `run()` would await `preCheck()`, and the preCheck it would * await belongs to the very polling session making the call, so the session would cancel * itself. Auto-IDLE is not restarted either, for the same reason: the caller is the idle loop. * * @param command Command name, as registered in the command registry. * @param args Arguments forwarded to the command implementation. * @returns Whatever the command implementation returns, or `false` for an * unknown command. * @internal */ async runInternal(command, ...args) { command = command.toUpperCase(); if (!this.commands.has(command)) { return false; } if (!this.socket || this.socket.destroyed) { throw this.createNoConnectionError(false, { rejectedFrom: 'noSocket', command }); } let handler = this.commands.get(command); try { return await handler(this, ...args); } finally { this.releaseOrphanedResponse(command); } } /** * Safety net for the `next()` contract of settleRequest(): a command handler that throws or * returns between the resolution of its exec() and the `next()` call would leave the reader * loop parked, stalling every later command until the socket timeout. Checked one macrotask * after the handler settled, so a concurrently running handler whose response has just been * resolved gets to release it itself first. * * @param command Command name, for the log entry. * @internal */ releaseOrphanedResponse(command) { let release = this.parkedRelease; if (!release) { return; } setImmediate(() => { if (this.parkedRelease === release) { this.log.warn({ msg: 'Command handler did not release its response', command, cid: this.id }); release(); } }); } // Mailbox lock queue processor. Implements a mutex pattern: only one lock // is active at a time. When the active lock is released, the next queued // lock is processed. The `processingLock` flag prevents concurrent runs // of this method (which could happen via setImmediate re-entry from release()). /** @internal */ async processLocks() { const wasProcessing = this.processingLock; if (wasProcessing) { // Another processor is already running; it will pick up new locks this.log.trace({ msg: 'Mailbox locking queued', path: this.mailbox && this.mailbox.path, pending: this.locks.length, idling: this.idling, activeLock: this.currentLock ? { lockId: this.currentLock.lockId, ...(this.currentLock.options?.description && { description: this.currentLock.options?.description }) } : null }); return; } this.processingLock = true; // The lock taken off the queue and not settled yet. A pass that throws out of its loop // rejects it, its caller would wait forever otherwise: it is in the queue no longer. let unsettled = null; try { // Process all locks in queue until empty let processedCount = 0; while (this.locks.length > 0) { // Mutex invariant: at most one lock may be held at a time. // If a lock is already granted, stop processing; release() will // clear currentLock and reschedule us to pick up the next queued lock. if (this.currentLock) { break; } // Yield to event loop periodically to prevent CPU blocking processedCount++; if (processedCount % 5 === 0) { await new Promise(resolve => setImmediate(resolve)); } const lock = this.locks.shift(); unsettled = lock; const { resolve, reject, path, options, lockId } = lock; // From here on the grant/reject path owns the outcome; the acquire // timer must not race with resolution. if (lock.acquireTimer) { clearTimer(lock.acquireTimer); lock.acquireTimer = null; } const armHeldTimer = () => { let threshold = Number(options.maxLockHoldTime ?? this.options.maxLockHoldTime ?? HELD_LOCK_WARN_MS); if (!threshold || threshold <= 0) { return; } lock.heldAt = Date.now(); // Background diagnostic: must not keep the process alive on its own lock.heldWarnTimer = setTimeout(() => { lock.heldWarnTimer = null; this.log.warn({ msg: 'Mailbox lock held for a long time', lockId: lock.lockId, path, heldFor: Date.now() - lock.heldAt, /* c8 ignore next */ // the held-lock-warning diagnostic with a description set is a timing-dependent log detail ...(options.description && { description: options.description }), cid: this.id }); }, threshold); unrefTimer(lock.heldWarnTimer); }; // release() is captured per-lock. It must only clear this.currentLock // if the caller still owns it - otherwise a stale release (after a // disconnect replaced the lock, or a double-release from user code) // would clear the new holder's lock and allow concurrent access. const release = () => { if (this.currentLock === lock) { if (lock.heldWarnTimer) { clearTimer(lock.heldWarnTimer); lock.heldWarnTimer = null; } this.log.trace({ msg: 'Mailbox lock released', lockId: lock.lockId, path: this.mailbox && this.mailbox.path, pending: this.locks.length, idling: this.idling }); this.currentLock = false; // autoidle() will not arm while a lock is held, so the release is what // restarts it. It re-checks the queue itself, so a lock waiting behind // this one still keeps IDLE off. this.autoidle(); // Use setImmediate to avoid stack overflow setImmediate(() => { this.processLocks().catch(err => this.log.error({ err, cid: this.id })); }); } else { this.log.trace({ msg: 'Ignoring stale lock release', lockId: lock.lockId, cid: this.id }); } }; if (!this.usable || !this.socket || this.socket.destroyed) { this.log.trace({ msg: 'Failed to acquire mailbox lock', path, lockId, idling: this.idling }); unsettled = null; reject(this.createNoConnectionError(false, { rejectedFrom: 'mailboxLock', path })); continue; // Process next lock in queue } // Both grant paths finish the same way. autoidle() is re-checked because a stale // auto-IDLE timer may still be armed at this point: on the SELECT path run() // re-arms auto-IDLE when the SELECT settles - a moment before currentLock is set - // and the fast path can inherit a timer from an earlier command. Either way the // timer must not fire inside the lock. const grantLock = () => { this.currentLock = lock; armHeldTimer(); this.autoidle(); unsettled = null; resolve({ path, release }); }; if (this.mailbox && this.mailbox.path === path && !!this.mailbox.readOnly === !!options.readOnly) { // Fast path: mailbox is already selected with the right access mode this.log.trace({ msg: 'Mailbox lock acquired [existing]', path, lockId, idling: this.idling, ...(options.description && { description: options.description }) }); grantLock(); break; // Stop processing; next lock waits for release() } try { // Need to SELECT/EXAMINE a different mailbox await this.mailboxOpen(path, options); if (!this.mailbox) { // mailboxOpen() resolves with nothing when the connection is no longer // authenticated (a BYE arrived meanwhile): no mailbox, so no lock on it throw this.createNoConnectionError(false, { rejectedFrom: 'mailboxLock', path }); } this.log.trace({ msg: 'Mailbox lock acquired [selected]', path, lockId, idling: this.idling, ...(options.description && { description: options.description }) }); grantLock(); break; // Wait for this lock to be released } catch (err) { // mailboxOpen() has already marked a missing mailbox (mailboxMissing) this.log.trace({ msg: 'Failed to acquire mailbox lock', path, lockId, idling: this.idling, ...(options.description && { description: options.description }), err }); unsettled = null; reject(err); // Continue to next lock in queue } } } catch (err) { if (unsettled) { let lock = unsettled; unsettled = null; // granted but not handed out (the grant itself failed): nobody can release it if (this.currentLock === lock) { clearTimer(lock.heldWarnTimer); lock.heldWarnTimer = null; this.currentLock = false; } lock.reject(err); } throw err; } finally { this.processingLock = false; // A pass that ended with locks queued and none held (it threw out of its loop) would // leave them waiting with nothing to start another pass. Schedule one. if (this.locks.length && !this.currentLock) { setImmediate(() => { this.processLocks().catch(err => this.log.error({ err, cid: this.id })); }); } } } /** * Opens a mailbox if not already open and returns a lock. Next call to `getMailboxLock()` is queued * until previous lock is released. This is suggested over {@link ImapFlow#mailboxOpen} as * `getMailboxLock()` gives you a weak transaction while `mailboxOpen()` has no guarantees whatsoever that another * mailbox is opened while you try to call multiple fetch or store commands. * * @param path **Path for the mailbox** to open * @param options optional options * @returns Mailbox lock * @throws Will throw an error if mailbox does not exist or can not be opened * * @example * let lock = await client.getMailboxLock('INBOX'); * try { * // do something in the mailbox * } finally { * // use finally{} to make sure lock is released even if exception occurs * lock.release(); * } */ getMailboxLock(path, options) { options = options || {}; let lockPath = normalizePath(this, path); let lockId = ++this.lockCounter; this.log.trace({ msg: 'Requesting lock', path: lockPath, lockId, ...(options.description && { description: options.description }), activeLock: this.currentLock ? { lockId: this.currentLock.lockId, ...(this.currentLock.options?.description && { description: this.currentLock.options?.description }) } : null }); const lockOptions = options; // Guarded: close() rejects every queued lock synchronously. See guardedPromise(). let lockPromise = guardedPromise((resolve, reject) => { let lockEntry = { resolve, reject, path: lockPath, options: lockOptions, lockId }; this.locks.push(lockEntry); // Opt-in acquire timeout: if the lock has not been granted within // acquireTimeout ms, remove it from the queue and reject. Only // affects queued (pending) locks - once granted, the timer is cleared. if (Number(lockOptions.acquireTimeout) > 0) { lockEntry.acquireTimer = setTimeout(() => { lockEntry.acquireTimer = null; const idx = this.locks.indexOf(lockEntry); if (idx !== -1) { this.locks.splice(idx, 1); let err = createImapError('Timed out waiting for mailbox lock', 'LockTimeout', { lockId: lockEntry.lockId }); reject(err); } }, Number(lockOptions.acquireTimeout)); } this.processLocks().catch(err => reject(err)); }); return lockPromise; } /** @internal */ getLogger() { let mainLogger = {}; if (this.options.logger && typeof this.options.logger === 'object') { mainLogger = this.options.logger; } else if (this.options.logger !== false) { // {logger:false} never consults mainLogger, so it does not create the default logger mainLogger = createConnectionLogger({ cid: this.id, logRaw: this.options.logRaw }); } // Whether the configured logger takes entries at this level: a disabled logger takes // none, a plain object of level methods the levels it has methods for, plus error and // fatal, which fall back to the console. The level methods and isLevelEnabled() below // share this one rule. const reachesLogger = (level) => this.options.logger !== false && (typeof mainLogger[level] === 'function' || level === 'error' || level === 'fatal'); let synteticLogger = {}; let levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']; for (let level of levels) { synteticLogger[level] = (...args) => { if (reachesLogger(level)) { const logMethod = mainLogger[level]; if (typeof logMethod === 'function') { logMethod.apply(mainLogger, args); } else { // error or fatal without a method of its own: the console, so it is never lost let entry = args[0]; try { if (entry && typeof entry === 'object' && entry.err) { entry = Object.assign({}, entry, { err: flattenLoggedError(entry.err) }); } console.error(JSON.stringify(entry)); } catch { // Serializing failed (a circular structure, a BigInt, a throwing // getter). This fallback exists so an error is never lost, so hand // the entry to console.error itself - it inspects rather than // serializes, and handles all three - instead of dropping it. console.error(entry); } } } if (this.emitLogs && args && args[0] && typeof args[0] === 'object') { // Guarded for the same reason as the console fallback above: a log call must // never throw. Most of these run inside catch blocks in the protocol // machinery, where a throw would escape the handler that was recovering from // something else and strand the connection. A throwing property getter on the // logged error and a throwing 'log' listener both end up here. try { let logEntry = Object.assign({ level, t: Date.now(), cid: this.id, lo: ++this.lo }, args[0]); if (logEntry.err) { logEntry.err = flattenLoggedError(logEntry.err); } this.emit('log', logEntry); } catch { // Nothing to do with it: reporting the failure would re-enter this // same path } } }; } // Whether an entry at this level reaches anyone, so the caller can skip building an // expensive one (a response serialized for the log) that nobody would see. 'log' events // carry every entry whatever the logger does with it, and a pino-like logger knows its // own threshold. synteticLogger.isLevelEnabled = (level) => this.emitLogs || (typeof mainLogger.isLevelEnabled === 'function' ? !!mainLogger.isLevelEnabled(level) : reachesLogger(level)); return synteticLogger; } /** * Whether a log entry at this level reaches anyone, so the caller can skip building an * expensive one. A logger assigned to `log` from outside may lack the method, and then * every level counts as enabled. * * @internal */ isLogLevelEnabled(level) { return typeof this.log.isLevelEnabled === 'function' ? this.log.isLevelEnabled(level) : true; } /** * Detaches sockets from the IMAP pipeline. Useful for upgrading the connection * (e.g., STARTTLS) or transferring socket ownership. Call it while the connection is not * idling: an IDLE in progress is not broken first, so the server still expects `DONE`. * * @returns Socket objects: `readSocket` is the read socket (inflated socket if compression is enabled, raw socket otherwise), * `writeSocket` the write socket and `socket` the raw underlying socket (same as readSocket/writeSocket when compression is disabled) */ unbind() { const socket = this.socket; socket.unpipe(this.streamer); if (this._inflate) { this._inflate.unpipe(this.streamer); } // Detach all of ImapFlow's socket listeners - the raw socket plus, when // compression is active, the PassThrough writeSocket - so the connection // is fully released to the caller. this.clearSocketHandlers(); // The socket now belongs to the caller. Marking the client unusable keeps // auto-IDLE (armed now, or re-armed by a lock release or a finished download) // and a later dispose from writing IDLE or LOGOUT onto it. this.usable = false; clearTimer(this.idleStartTimer); const readSocket = this._inflate || socket; const writeSocket = this.writeSocket || socket; // Defense-in-depth: when compression is active the raw socket is orphaned // (neither readSocket nor writeSocket) yet still live and still the target // of the deflate/writeSocket error forwarders. We just stripped our own // error listener, so any post-unbind error (e.g. an upstream ECONNRESET) // would become an unhandled 'error' that crashes the host process. Attach // a benign listener so the orphaned socket can never throw after handoff. // Non-compression path: socket === readSocket === writeSocket and the // caller owns it directly, so leave it untouched (no behavior change). if (socket !== readSocket && socket !== writeSocket) { socket.on('error', err => { this.log.debug({ msg: 'Suppressed error on unbound socket', err, cid: this.id }); }); } return { readSocket, writeSocket, socket }; } } /** * Connection close event. **NB!** ImapFlow does not handle reconnects automatically. * So whenever a 'close' event occurs you must create a new connection yourself. * * @event ImapFlow#close */ /** * Error event. In most cases getting an error event also means that connection is closed * and pending operations should return with a failure. Emitted at most once per connection, * later errors while the connection is closing are only logged. * * @event ImapFlow#error * @example * client.on('error', err=>{ * console.log(`Error occurred: ${err.message}`); * }); */ /** * Message count in currently opened mailbox changed * * @event ImapFlow#exists * @example * client.on('exists', data=>{ * console.log(`Message count in "${data.path}" is ${data.count}`); * }); */ /** * Deleted message sequence number in currently opened mailbox. One event is fired for every deleted email. * * @event ImapFlow#expunge * @example * client.on('expunge', data=>{ * console.log(`Message #${data.seq} was deleted from "${data.path}"`); * }); */ /** * Flags were updated for a message. Not all servers fire this event. * * @event ImapFlow#flags * @example * client.on('flags', data=>{ * console.log(`Flag set for #${data.seq} is now "${Array.from(data.flags).join(', ')}"`); * }); */ /** * Mailbox was opened * * @event ImapFlow#mailboxOpen * @example * client.on('mailboxOpen', mailbox => { * console.log(`Mailbox ${mailbox.path} was opened`); * }); */ /** * Mailbox was closed * * Emitted both when a selected mailbox is closed explicitly, by `mailboxClose()` or by * selecting a different mailbox, and when the connection itself goes away while a mailbox * was still selected, whether through a clean logout or a lost transport. The transition is * reported once per selected mailbox, before the `close` event. * * @event ImapFlow#mailboxClose * @example * client.on('mailboxClose', mailbox => { * console.log(`Mailbox ${mailbox.path} was closed`); * }); */ /** * Log event if `emitLogs=true` * * @event ImapFlow#log * @example * client.on('log', entry => { * console.log(`${entry.cid} ${entry.msg}`); * }); */ // Installed outside the class body: a computed `[Symbol.asyncDispose]` key would turn into a // method named "undefined" on Node.js 20.0-20.3, which predate the symbol if (typeof Symbol.asyncDispose === 'symbol') { ImapFlow.prototype[Symbol.asyncDispose] = async function () { if (this.usable) { try { await this.logout(); } catch (err) { logConnectionError(this, 'LOGOUT failed while disposing', err); } } // logout() already closes the connection; close() is idempotent and covers a client // that never connected or whose logout was skipped this.close(); }; } // Both `import { ImapFlow } from 'imapflow'` and `import imapflow from 'imapflow'` work, the // latter matching the shape `require('imapflow')` has always had const imapflow = { ImapFlow, AuthenticationFailure }; export default imapflow;