"use strict"; /* eslint no-control-regex:0 */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.noop = exports.MAX_UINT32_DIGITS = exports.MAX_COLLECTED_RESPONSES = exports.EXPANDED_RANGE_LIMIT = exports.AuthenticationFailure = void 0; exports.isIpLiteral = isIpLiteral; exports.buildConnectionError = buildConnectionError; exports.restampConnectionError = restampConnectionError; exports.guardedPromise = guardedPromise; exports.guardedReject = guardedReject; exports.clearTimer = clearTimer; exports.unrefTimer = unrefTimer; exports.logConnectionError = logConnectionError; exports.isServerRefusal = isServerRefusal; exports.isRev2Active = isRev2Active; exports.hasCapability = hasCapability; exports.buildStatusQueryAttributes = buildStatusQueryAttributes; exports.encodePath = encodePath; exports.decodePath = decodePath; exports.normalizePath = normalizePath; exports.comparePaths = comparePaths; exports.updateCapabilities = updateCapabilities; exports.getStatusCode = getStatusCode; exports.emitSafe = emitSafe; exports.getTextValues = getTextValues; exports.getErrorText = getErrorText; exports.enhanceCommandError = enhanceCommandError; exports.reportCommandError = reportCommandError; exports.isAuthenticatedState = isAuthenticatedState; exports.getSelectedMailbox = getSelectedMailbox; exports.getFolderTree = getFolderTree; exports.getFlagColor = getFlagColor; exports.getColorFlags = getColorFlags; exports.mergeFetchRows = mergeFetchRows; exports.formatMessageResponse = formatMessageResponse; exports.processName = processName; exports.decodeText = decodeText; exports.parseEnvelope = parseEnvelope; exports.getStructuredParams = getStructuredParams; exports.parseBodystructure = parseBodystructure; exports.isDate = isDate; exports.toValidDate = toValidDate; exports.formatDate = formatDate; exports.formatDateTime = formatDateTime; exports.formatFlag = formatFlag; exports.canUseFlag = canUseFlag; exports.isValidSequenceValue = isValidSequenceValue; exports.isDecimalString = isDecimalString; exports.isUnsafeKey = isUnsafeKey; exports.getStringList = getStringList; exports.getStringValue = getStringValue; exports.parseBigIntValue = parseBigIntValue; exports.parseUintValue = parseUintValue; exports.iterateRange = iterateRange; exports.countRange = countRange; exports.expandRange = expandRange; exports.getDecoder = getDecoder; exports.packMessageRange = packMessageRange; const libmime_1 = __importDefault(require("libmime")); const charsets_js_1 = require("./charsets.js"); const imap_handler_js_1 = require("./handler/imap-handler.js"); const imap_formal_syntax_js_1 = __importDefault(require("./handler/imap-formal-syntax.js")); const node_crypto_1 = require("node:crypto"); const jp_decoder_js_1 = require("./jp-decoder.js"); const iconv_lite_1 = __importDefault(require("iconv-lite")); const errors_js_1 = require("./errors.js"); var errors_js_2 = require("./errors.js"); Object.defineProperty(exports, "AuthenticationFailure", { enumerable: true, get: function () { return errors_js_2.AuthenticationFailure; } }); const FLAG_COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'grey']; // Error codes that only mean the connection is no longer usable. See logConnectionError(). const CONNECTION_GONE_CODES = new Set([errors_js_1.ImapFlowErrorCode.NoConnection, errors_js_1.ImapFlowErrorCode.EConnectionClosed, errors_js_1.ImapFlowErrorCode.StateLogout]); // Upper bound for expanding server-supplied sequence ranges (see expandRange). 2^24 // entries in total is far beyond any legitimate mailbox while keeping the worst-case // expansion of a hostile range set bounded. // // Shared bound for expanding server-supplied sequence sets. Exported so other places that // expand server sequences (e.g. ESEARCH ALL in commands/search.ts) can apply the same absolute // ceiling instead of inventing their own. exports.EXPANDED_RANGE_LIMIT = 0x1000000; // Upper bound for the untagged responses one command may accumulate in memory: the FETCH rows // fetchAll() collects and the mailboxes list() collects. The response size limits bound one // response at a time, not how many of them a server sends in reply to one command, so a hostile // server answering a single-message FETCH with an endless stream of unsolicited FETCH rows (or a // LIST with endless mailboxes) would otherwise grow the process without limit. Generous on // purpose: a million rows is far beyond any legitimate listing, and a mailbox that large is read // through the streaming fetch() generator, which keeps nothing. Reaching it therefore fails the // command (code TooManyResponses) instead of silently truncating its result. exports.MAX_COLLECTED_RESPONSES = 1000000; // Digit bounds for untrusted numeric values in server responses. UIDs, UIDVALIDITY and // message counts are 32-bit unsigned (nz-number in the RFC 9051 grammar, so at most 10 // digits); MODSEQ and number64 values are 63-bit unsigned (RFC 7162, RFC 9051), at most // 19 digits. The bound is checked before BigInt()/Number(): a response line may carry up // to maxLineLength digits, and BigInt() on a multi-megabyte digit run costs hundreds of // milliseconds of non-yielding CPU. // // MAX_UINT32_DIGITS is exported so call sites can ask for the tighter 32-bit bound where the // grammar requires it (UID, UIDVALIDITY, message counts) instead of the parsers' 63-bit default. exports.MAX_UINT32_DIGITS = 10; const MAX_NUMBER64_DIGITS = 19; // Object keys that reach through the prototype chain when assigned to, or resolve to an // inherited member when read. Server-controlled strings become keys in several places // (STATUS items, QUOTA resources, BODYSTRUCTURE parameters, FETCH body part names), so they // all consult this one set rather than each carrying its own list. const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']); // Extensions that RFC 9051 (IMAP4rev2) folds into the base protocol (Appendix E). // When IMAP4rev2 is active, these are available even without their own capability // token. BINARY is deliberately excluded - RFC 9051 only folds in the FETCH side, // which fetch.ts handles with its own isRev2Active check, while the APPEND side // stays gated on the BINARY token. SPECIAL-USE is a partial fold: Appendix E only // folds in the special-use mailbox attributes, not the RFC 6154 LIST selection and // RETURN options - the only call sites that act on this entry are in list.ts, // where a staged retry ladder recovers if a rev2-only server rejects the RETURN // option. The set mirrors the rest of the Appendix E list in full, including // entries no call site consults yet, so any future capability check gets the // rev2 folding for free. const IMAP4REV2_FOLDED_CAPABILITIES = new Set([ 'ENABLE', 'ESEARCH', 'IDLE', 'LIST-EXTENDED', 'LIST-STATUS', 'LITERAL-', 'MOVE', 'NAMESPACE', 'SASL-IR', 'SEARCHRES', 'SPECIAL-USE', 'STATUS=SIZE', 'UIDPLUS', 'UNSELECT' ]); // Deliberate no-op, used as the observer a guarded promise attaches to its own rejection. const noop = () => { }; exports.noop = noop; /** * Tells an IP literal from a hostname without `node:net`, which some runtimes do not implement. * Only used to decide whether a host can be a TLS servername: a dotted quad, or anything with a * colon (an IPv6 literal, a hostname never contains one). * * @param host - Hostname or IP address * @returns true for an IP literal */ function isIpLiteral(host) { return /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':'); } // The fields buildConnectionError() stamps to say *where* a connection error was rejected, as // opposed to what went wrong. restampConnectionError() clears them, because they belong to the // site that built the error rather than to the failure it describes. const CONNECTION_ERROR_SITE_KEYS = ['rejectedFrom', 'command', 'path']; /** * Builds an error describing a connection that is gone, stamped so it can be traced back to * where it came from: the connection id always travels on it, and each site names itself * through `meta` (`rejectedFrom`, plus the command or mailbox path it belongs to). * * A stack trace only records where an error was built, and close() hands a rejection to every * pending request and every queued lock in the same tick, so without these an error that * reaches a global unhandledRejection handler arrives with nothing that identifies the * connection it came from, let alone which of the rejected promises carried it. * * Takes the connection id rather than the connection, so the stamping stays in one place * without every caller having to be a full ImapFlow instance. * * @param cid - Connection id * @param code - Error code, e.g. 'NoConnection' * @param message - Error message * @param meta - Fields to stamp on the error * @returns The stamped error */ function buildConnectionError(cid, code, message, meta) { return (0, errors_js_1.createImapError)(message, code, { cid, ...meta }); } /** * Re-stamps an existing connection error for a different rejection site. * * The same failure can be handed to more than one promise - close() rejects the in-flight * command, and runIdle() then rejects everything queued behind it - and each of those is a * separate promise with a separate consumer. Sharing one error object reports whichever of * them escapes under the first site's marker, which is the attribution these markers exist to * give. * * Everything describing *what went wrong* is carried over, because a re-stamped error reaches * user code through run() and callers branch on `responseStatus`, `serverResponseCode` and * friends. Everything describing *where it was rejected* is dropped, because the new site owns * those and a leftover `command` from the previous site is exactly as misleading as a leftover * `rejectedFrom`. The original travels on as `cause`. * * @param err - The error being re-stamped * @param meta - Fields for the new site, e.g. { rejectedFrom: 'preCheckWaiter' } * @returns A separate error describing the same failure at the new site */ function restampConnectionError(err, meta) { // the original code is carried over unchanged, whatever its origin let error = buildConnectionError(err.cid, err.code, err.message, err); for (let key of CONNECTION_ERROR_SITE_KEYS) { delete error[key]; } if (meta) { Object.assign(error, meta); } error.cause = err; return error; } /** * Creates a promise whose rejection is observed as soon as it exists. * * close() rejects every promise it owns - the in-flight and queued commands, the pending * connect(), the queued mailbox locks, the waiters for an IDLE break - synchronously, from a * socket event. A consumer that only reaches its `await` a microtask later has not attached a * handler yet at the moment Node decides whether the rejection was observed, and the whole * worker dies on the resulting unhandledRejection. The pre-attached observer settles that * question; the rejection still propagates normally to whoever awaits the returned promise. * * Creation and guarding are one call because splitting them is what actually goes wrong: the * guard was hand-attached at three of the four sites and the fourth (the IDLE-break waiter) * went unguarded, on exactly the path a server BYE takes. * * @param executor - Promise executor, (resolve, reject) => {} * @returns The promise, with its rejection already observed */ function guardedPromise(executor) { let promise = new Promise(executor); promise.catch(exports.noop); return promise; } /** * The already-rejected form of guardedPromise(), for a call that has to hand back a rejected * promise rather than throw. * * @param error - Rejection reason * @returns Rejected promise, with its rejection already observed */ function guardedReject(error) { let promise = Promise.reject(error); promise.catch(exports.noop); return promise; } /** * Clears a timer that may already have been dropped. `clearTimeout()` accepts undefined but not * null, and the connection nulls its timer fields once cleared, so every site clears through here. * * @param timer - Timer handle returned by setTimeout, or null/undefined when none is armed */ function clearTimer(timer) { if (timer) { clearTimeout(timer); } } /** * Detaches a background timer from the event loop, so it cannot keep the process alive on its * own. Applied to every background timer (auto-IDLE, IDLE restart, fallback polling, throttle * back-off, held-lock diagnostics); connection and greeting deadlines are deliberately left * attached, because a caller is waiting for connect() to settle. * * @param timer - Timer handle returned by setTimeout * @returns The same timer handle */ function unrefTimer(timer) { /* c8 ignore next 3 */ // node timers always expose unref(); the guard covers replaced globals in tests if (timer && typeof timer.unref === 'function') { timer.unref(); } return timer; } /** * Logs a failure from background connection work at the level its cause deserves. * * Background work (IDLE sessions, polling timers, auto-IDLE) is interrupted by every normal * disconnect, so a rejection carrying one of the CONNECTION_GONE_CODES is expected rather * than notable and goes to debug. The three codes describe the same situation reached * through different guards: write() throws NoConnection or StateLogout, exec() rejects * EConnectionClosed for the window where the socket is destroyed but close() has not run * yet, and close() rejects pending requests with NoConnection. * * A connection error carrying `reason` is the exception. That field holds the server's * untagged BYE text ("Too many simultaneous connections", "Account is disabled"), which * serverBye() only records - this log call is the one place it becomes visible, and it is * usually the answer to why a client is reconnecting in a loop. Those stay at warn. * * Shared so the classification cannot drift between the call sites that make this decision. * * @param connection - IMAP connection instance * @param msg - What failed, so the entries stay distinguishable in the log * @param err - The error to log */ function logConnectionError(connection, msg, err) { let routine = !!err && CONNECTION_GONE_CODES.has(err.code) && !err.reason; connection.log[routine ? 'debug' : 'warn']({ msg, err, cid: connection.id }); } /** * Whether a command failed because the server answered it with a tagged NO or BAD, as opposed * to a lost connection, a timeout or a local failure that says nothing about the server's answer. * A throttled command (ETHROTTLE, Microsoft 365 answers it with BAD) was not refused: the server * asks to try again later, so a throttled login is not a rejected credential and a throttled * IDLE does not mean the server lacks IDLE. * * @param err - The error the command failed with * @returns True for a tagged NO or BAD that is not throttling */ function isServerRefusal(err) { return !!err && (err.responseStatus === 'NO' || err.responseStatus === 'BAD') && err.code !== errors_js_1.ImapFlowErrorCode.ETHROTTLE; } /** * Checks whether IMAP4rev2 semantics are active for the connection: either the * client enabled IMAP4rev2 explicitly, or the server is rev2-only (advertises * IMAP4rev2 without IMAP4rev1), in which case rev2 is the base protocol without * any ENABLE (RFC 9051 Appendix A). UTF-8 mailbox names apply in both cases. * * @param connection - IMAP connection instance * @returns True if IMAP4rev2 semantics apply to this session */ function isRev2Active(connection) { return connection.enabled.has('IMAP4REV2') || (connection.capabilities.has('IMAP4rev2') && !connection.capabilities.has('IMAP4rev1')); } /** * Checks a capability, accounting for extensions that RFC 9051 folds into base * IMAP4rev2. Falls back to the plain capability lookup on IMAP4rev1 sessions, * so behavior against rev1 servers is unchanged. * * @param connection - IMAP connection instance * @param capability - Capability name, e.g. 'UIDPLUS' * @returns True if the capability (or its rev2-folded equivalent) is available */ function hasCapability(connection, capability) { if (connection.capabilities.has(capability)) { return true; } return IMAP4REV2_FOLDED_CAPABILITIES.has(capability) && isRev2Active(connection); } /** * Builds the attribute list for a STATUS request - the standalone STATUS command * or the LIST-STATUS return option - from a status query object. Items the current * session cannot request (RECENT under IMAP4rev2, HIGHESTMODSEQ without CONDSTORE) * are silently dropped. * * @param connection - IMAP connection instance * @param statusQuery - Status data items to request, e.g. {messages: true} * @returns Attribute token list for the command compiler */ function buildStatusQueryAttributes(connection, statusQuery) { let attributes = []; let query = (statusQuery || {}); Object.keys(query).forEach(key => { if (!query[key]) { return; } switch (key.toUpperCase()) { case 'MESSAGES': case 'UIDNEXT': case 'UIDVALIDITY': case 'UNSEEN': attributes.push({ type: 'ATOM', value: key.toUpperCase() }); break; case 'RECENT': // RECENT was removed in IMAP4rev2 (RFC 9051) - requesting it from a // rev2 session would get the whole STATUS request rejected if (!isRev2Active(connection)) { attributes.push({ type: 'ATOM', value: key.toUpperCase() }); } break; case 'HIGHESTMODSEQ': // QRESYNC implies CONDSTORE (RFC 7162 3.2.3) if (connection.capabilities.has('CONDSTORE') || connection.capabilities.has('QRESYNC')) { attributes.push({ type: 'ATOM', value: key.toUpperCase() }); } break; case 'SIZE': // STATUS SIZE requires the STATUS=SIZE extension (RFC 8438), which // RFC 9051 folds into base IMAP4rev2 if (hasCapability(connection, 'STATUS=SIZE')) { attributes.push({ type: 'ATOM', value: key.toUpperCase() }); } break; case 'DELETED': // STATUS DELETED is a base IMAP4rev2 addition (RFC 9051 Appendix E // item 3) with no standalone capability - requesting it from a plain // rev1 server would get the whole STATUS request rejected. RFC 9208 // additionally makes it mandatory when QUOTA=RES-MESSAGE is advertised. if (isRev2Active(connection) || connection.capabilities.has('QUOTA=RES-MESSAGE')) { attributes.push({ type: 'ATOM', value: key.toUpperCase() }); } break; } }); return attributes; } /** * Encodes a mailbox path to modified UTF-7 if the server does not support UTF8=ACCEPT. * * @param connection - IMAP connection instance * @param path - Mailbox path to encode * @returns Encoded mailbox path */ function encodePath(connection, path) { path = (path || '').toString(); if (!connection.enabled.has('UTF8=ACCEPT') && !isRev2Active(connection) && /[&\x00-\x08\x0b-\x0c\x0e-\x1f\u0080-\uffff]/.test(path)) { try { path = iconv_lite_1.default.encode(path, 'utf-7-imap').toString(); } catch { // ignore, keep name as is } } return path; } /** * Decodes a mailbox path from modified UTF-7 if the server does not support UTF8=ACCEPT. * * @param connection - IMAP connection instance * @param path - Mailbox path to decode * @returns Decoded mailbox path */ function decodePath(connection, path) { path = (path || '').toString(); if (!connection.enabled.has('UTF8=ACCEPT') && !isRev2Active(connection) && /[&]/.test(path)) { try { path = iconv_lite_1.default.decode(Buffer.from(path), 'utf-7-imap').toString(); } catch { // ignore, keep name as is } } return path; } /** * Normalizes a mailbox path by joining array segments with the namespace delimiter, * uppercasing INBOX, and prepending the namespace prefix if needed. * * @param connection - IMAP connection instance * @param path - Mailbox path or array of path segments * @param skipNamespace - If true, skips prepending the namespace prefix * @returns Normalized mailbox path */ function normalizePath(connection, path, skipNamespace) { if (Array.isArray(path)) { path = path.join((connection.namespace && connection.namespace.delimiter) || ''); } if (path.toUpperCase() === 'INBOX') { // inbox is not case sensitive return 'INBOX'; } // ensure namespace prefix if needed if (!skipNamespace && connection.namespace && connection.namespace.prefix && !path.startsWith(connection.namespace.prefix)) { path = connection.namespace.prefix + path; } return path; } /** * Compares two mailbox paths for equality after normalization. * * @param connection - IMAP connection instance * @param a - First mailbox path * @param b - Second mailbox path * @returns True if the paths are equal after normalization */ function comparePaths(connection, a, b) { if (!a || !b) { return false; } return normalizePath(connection, a) === normalizePath(connection, b); } /** * Parses a capability response list into a Map of capability names to values. * * @param list - Array of capability objects from IMAP response * @returns Map of capability names to `true` or numeric values */ function updateCapabilities(list) { let map = new Map(); if (list && Array.isArray(list)) { list.forEach(val => { // any entry can be a parsed NIL if (!val || typeof val.value !== 'string') { return; } let capability = val.value.toUpperCase().trim(); if (capability === 'IMAP4REV1') { map.set('IMAP4rev1', true); return; } if (capability === 'IMAP4REV2') { map.set('IMAP4rev2', true); return; } if (capability.startsWith('APPENDLIMIT=')) { let splitPos = capability.indexOf('='); map.set('APPENDLIMIT', parseUintValue(capability.slice(splitPos + 1)) || 0); return; } map.set(capability, true); }); } return map; } /** * Extracts the IMAP response status code (e.g. AUTHENTICATIONFAILED, NONEXISTENT) * from a parsed server response. * * @param response - Parsed IMAP server response * @returns Uppercase status code string, or false if not found */ function getStatusCode(response) { return response && typeof response === 'object' && response.attributes && response.attributes[0] && response.attributes[0].section && response.attributes[0].section[0] && typeof response.attributes[0].section[0].value === 'string' ? response.attributes[0].section[0].value.toUpperCase().trim() : false; } /** * Emits a state-change event from inside the command pipeline. A listener that throws must not * abort the code that emitted it: select() emits before it releases the response, so the throw * would leave the reader loop waiting forever, and close() would never get to emit 'close'. The * error is logged instead, the same contract untagged handlers and the 'response' event get. * * @param connection - IMAP connection instance * @param event - Event name * @param args - Event arguments */ function emitSafe(connection, event, ...args) { try { connection.emit(event, ...args); } catch (err) { connection.log.warn({ msg: 'Event listener failed', event, err, cid: connection.id }); } } /** * Collects the values of the TEXT tokens of a parsed response (the human-readable * part of a status response, a greeting or a BYE). * * @param attributes - Attributes of a parsed IMAP response * @returns Values of the TEXT tokens, in order */ function getTextValues(attributes) { return (attributes || []).filter(attr => attr?.type === 'TEXT').map(attr => String(attr?.value ?? '')); } /** * Compiles an IMAP response object back into a human-readable string. * * @param response - Parsed IMAP server response * @returns Compiled response text, or false if no response */ // Async although nothing in it awaits any more: `imapflow/lib/tools` deep imports may chain on the promise async function getErrorText(response) { if (!response) { return false; } try { return (0, imap_handler_js_1.compiler)(response).toString(); } catch { // The wire encoder refuses values that cannot be expressed as a valid IMAP // string, which is what keeps user-supplied data from breaking out of a // command. A server response is not held to that: the parser deliberately // tolerates stray bytes inside an OK/NO/BAD atom, and those bytes then have // no valid re-encoding. This text is diagnostic, so fall back to the logging // encoder rather than replacing the server's error with an encoding failure. return (0, imap_handler_js_1.compiler)(response, { isLogging: true }).toString(); } } /** * Enhances an IMAP command error with the server response code and text. * * @param err - Error object with a `response` property * @returns The enhanced error with `serverResponseCode` and string `response` */ async function enhanceCommandError(err) { let errorCode = getStatusCode(err.response); if (errorCode) { err.serverResponseCode = errorCode; } err.response = await getErrorText(err.response); return err; } /** * Enhances a failed command's error (see enhanceCommandError()) and logs it, the shared first * step of every command's failure path. The caller decides whether to throw or return. * * @param connection - IMAP connection instance * @param err - The command error */ async function reportCommandError(connection, err) { await enhanceCommandError(err); connection.log.warn({ err, cid: connection.id }); } /** * Whether the session is authenticated, that is in the AUTHENTICATED or SELECTED state, which * every mailbox-level command requires. * * @param connection - IMAP connection instance */ function isAuthenticatedState(connection) { return connection.state === connection.states.AUTHENTICATED || connection.state === connection.states.SELECTED; } /** * Returns the selected mailbox, or false when the connection is not in the SELECTED state. * Message-level commands use it as their precondition, which also narrows the mailbox type. * * @param connection - IMAP connection instance */ function getSelectedMailbox(connection) { return connection.state === connection.states.SELECTED && connection.mailbox ? connection.mailbox : false; } /** * Converts a flat list of mailbox folders into a tree structure. * * @param folders - Array of folder objects from LIST/LSUB response * @returns Tree structure with a `root` flag and nested `folders` arrays */ function getFolderTree(folders) { let tree = { root: true, folders: [] }; // Children of every node indexed by name. Looking a child up with folders.find() made the // tree quadratic in the number of siblings: a listing of 50k mailboxes at one level (a size // the server chooses) took seconds of event loop time. A name is unique among its siblings, // as the loop below updates an existing entry rather than adding a second one. let childIndex = new Map(); let childByName = (node, name) => { let index = childIndex.get(node); return index ? index.get(name) : undefined; }; let indexChild = (node, child) => { let index = childIndex.get(node); if (!index) { index = new Map(); childIndex.set(node, index); } index.set(child.name, child); }; let getTreeNode = (parents) => { let node = tree; if (!parents || !parents.length) { return node; } for (let parent of parents) { let cur = childByName(node, parent); if (cur) { node = cur; } } return node; }; // Parents are inserted before their children, as getTreeNode() can only descend into nodes // that already exist. LIST gives no ordering guarantee, and the LIST command sorts special-use // mailboxes first, which put a child such as "[Gmail]/Sent Mail" ahead of "[Gmail]" and left // it at the root. The sort is stable, so siblings keep their listing order let byDepth = [...folders].sort((a, b) => (a.parent ? a.parent.length : 0) - (b.parent ? b.parent.length : 0)); for (let folder of byDepth) { let parent = getTreeNode(folder.parent); // see if entry already exists let existing = childByName(parent, folder.name); if (existing) { // update values existing.name = folder.name; existing.flags = folder.flags; existing.path = folder.path; existing.subscribed = !!folder.subscribed; existing.listed = !!folder.listed; existing.status = folder.status; if (folder.specialUse) { existing.specialUse = folder.specialUse; } if (folder.flags.has('\\Noselect')) { existing.disabled = true; } if (folder.flags.has('\\HasChildren') && !existing.folders) { existing.folders = []; } } else { // create new let data = { name: folder.name, flags: folder.flags, path: folder.path, subscribed: !!folder.subscribed, listed: !!folder.listed, status: folder.status }; if (folder.delimiter) { data.delimiter = folder.delimiter; } if (folder.specialUse) { data.specialUse = folder.specialUse; } if (folder.flags.has('\\Noselect')) { data.disabled = true; } if (folder.flags.has('\\HasChildren')) { data.folders = []; } if (!parent.folders) { parent.folders = []; } parent.folders.push(data); indexChild(parent, data); } } return tree; } /** * Derives a flag color name from a message's flags Set using Apple Mail color flag rules. * * @param flags - Message flags Set * @returns Color name (e.g. 'red', 'orange') or null if not flagged */ function getFlagColor(flags) { if (!flags.has('\\Flagged')) { return null; } // Apple Mail encodes flag colors as a 3-bit value using $MailFlagBit0/1/2 keywords. // Bit 0 = 1, Bit 1 = 2, Bit 2 = 4. The resulting integer (0-6) indexes into FLAG_COLORS: // 0=red, 1=orange, 2=yellow, 3=green, 4=blue, 5=purple, 6=grey. // Value 7 (all bits set) is unused; defaults to red. const bit0 = flags.has('$MailFlagBit0') ? 1 : 0; const bit1 = flags.has('$MailFlagBit1') ? 2 : 0; const bit2 = flags.has('$MailFlagBit2') ? 4 : 0; const color = bit0 | bit1 | bit2; // eslint-disable-line no-bitwise return FLAG_COLORS[color] ?? 'red'; // default to red for the unused \b111 } /** * Converts a color name to the corresponding flag add/remove operations for Apple Mail color flags. * * @param color - Color name (e.g. 'red', 'orange', 'yellow') * @returns Object with `add` and `remove` arrays of flag strings, or null if invalid color */ function getColorFlags(color) { // Reverse mapping from a color name to the Apple Mail $MailFlagBit0/1/2 flags. // Returns an object with 'add' and 'remove' arrays so the caller can STORE +FLAGS/-FLAGS. const colorCode = color ? FLAG_COLORS.indexOf(color.toString().toLowerCase().trim()) : null; if (colorCode === null || colorCode < 0) { if (colorCode === null) { // Remove color: remove \Flagged and all MailFlagBit flags return { add: [], remove: ['\\Flagged', '$MailFlagBit0', '$MailFlagBit1', '$MailFlagBit2'] }; } return null; } // Decompose color index back into its 3-bit representation let result = { add: ['\\Flagged'], remove: [] }; for (let i = 0; i < 3; i++) { // eslint-disable-next-line no-bitwise if (colorCode & (1 << i)) { result.add.push(`$MailFlagBit${i}`); } else { result.remove.push(`$MailFlagBit${i}`); } } return result; } /** * Merges the FETCH rows a server sent for one message into one message object. A server may * split the data items of a message over several FETCH responses, and may put an unsolicited * one (a flag change made by another session) in between (RFC 9051 sections 7.5.2 and 5.2). * Later rows win for single values, bodyParts, binaryParts and partialOrigins are combined. * * @param rows - Formatted rows of the same message, in the order they arrived * @returns One message object holding the data of every row */ function mergeFetchRows(rows) { if (rows.length === 1) { return rows[0]; } let merged = {}; let partialOrigins; for (let row of rows) { let { bodyParts, binaryParts, ...rest } = row; Object.assign(merged, rest); // combined into collections of their own, so the rows stay untouched bodyParts?.forEach((value, key) => (merged.bodyParts ??= new Map()).set(key, value)); binaryParts?.forEach(key => (merged.binaryParts ??= new Set()).add(key)); row.partialOrigins?.forEach((value, key) => (partialOrigins ??= new Map()).set(key, value)); } if (partialOrigins) { Object.defineProperty(merged, 'partialOrigins', { value: partialOrigins, writable: true, configurable: true }); } return merged; } /** * Formats a raw untagged FETCH response into a structured message object. * * @param untagged - Parsed untagged IMAP response * @param mailbox - Current mailbox state object * @param idHashAlgorithm - Hash for the fallback message id, `md5` unless the client was told otherwise * @param connection - Connection the response arrived on, decodes Gmail labels like mailbox names * @returns Formatted message object with properties like seq, uid, flags, envelope, etc. */ // Async although nothing in it awaits any more: `imapflow/lib/tools` deep imports may chain on the promise async function formatMessageResponse(untagged, mailbox, idHashAlgorithm, connection) { let map = {}; // The sequence number indexes into mailbox state, so an unusable one is dropped rather // than coerced to NaN or Infinity map.seq = parseUintValue(untagged.command, exports.MAX_UINT32_DIGITS) || undefined; let key; // the of a partial section ("BODY[2]<1024>"), kept for the value that follows let origin = false; let partialOrigins; let recordOrigin = (sectionKey) => { if (origin !== false) { if (!partialOrigins) { partialOrigins = new Map(); } partialOrigins.set(sectionKey, origin); } }; let attributes = ((untagged.attributes && untagged.attributes[1]) || []); for (let i = 0, len = attributes.length; i < len; i++) { let attribute = attributes[i]; if (i % 2 === 0) { origin = false; key = (0, imap_handler_js_1.compiler)({ attributes: [attribute] }) .toString() .toLowerCase() .replace(/<(\d+)(\.\d+)?>$/, (match, start) => { origin = parseUintValue(start, exports.MAX_UINT32_DIGITS); return ''; }); continue; } /* c8 ignore start */ // defensive: key is always a string produced by the compiler above if (typeof key !== 'string') { // should not happen continue; } /* c8 ignore stop */ let getString = (attribute) => { if (!attribute) { return false; } if (typeof attribute.value === 'string') { return attribute.value; } if (Buffer.isBuffer(attribute.value)) { return attribute.value.toString(); } }; let getBuffer = (attribute) => { if (!attribute) { return false; } if (Buffer.isBuffer(attribute.value)) { return attribute.value; } // A section is an nstring, so a server may answer with a quoted string // instead of a literal (Yahoo does for small parts). The tokenizer decoded // the line as UTF-8, so encoding the same way restores the bytes it sent. if (typeof attribute.value === 'string') { return Buffer.from(attribute.value); } }; // NIL (parsed as null) and other non-array values yield an empty array, so callers // can safely index into the result. RFC 8474 allows e.g. `THREADID NIL` when the // server has no thread relation to report. let getArray = (attribute) => getStringList(attribute); // Counts, sizes and UIDs are written into mailbox state and into range // computations, so only a bounded decimal run is usable - see parseUintValue(). let getUint = (attribute, maxDigits) => parseUintValue(getString(attribute), maxDigits); switch (key) { case 'body[]': case 'binary[]': map.source = getBuffer(attribute); recordOrigin(''); break; case 'uid': // A UID feeds mailbox.uidNext one line below, and from there every range // computation, so an unusable one is dropped rather than coerced map.uid = getUint(attribute, exports.MAX_UINT32_DIGITS) || undefined; // If the UID we just saw is >= the mailbox's uidNext, bump uidNext. // This keeps the local uidNext estimate current without requiring a // separate STATUS command, handling cases where new messages arrived // since the last SELECT/EXAMINE. if (map.uid && (!mailbox.uidNext || mailbox.uidNext <= map.uid)) { mailbox.uidNext = map.uid + 1; } break; case 'modseq': { // BigInt() throws on a non-numeric or missing value, and the throw // drops the whole message from the result set - so a malformed // MODSEQ from the server must be skipped, not surfaced. let modseq = parseBigIntValue(getArray(attribute)[0]); if (modseq === false) { break; } map.modseq = modseq; // Similarly, keep the local highestModseq estimate up to date. // This is critical for CONDSTORE/QRESYNC delta syncing. if (map.modseq && (!mailbox.highestModseq || mailbox.highestModseq < map.modseq)) { mailbox.highestModseq = map.modseq; } break; } case 'emailid': // OBJECTID extension (RFC 8474): server-assigned stable email identifier map.emailId = getArray(attribute)[0]; break; case 'x-gm-msgid': // Gmail extension: X-GM-MSGID is Gmail's unique message ID. // Mapped to the same emailId field as OBJECTID for a unified API, // but this is a Gmail-specific numeric string, not an RFC 8474 ObjectID. map.emailId = getString(attribute); break; case 'threadid': map.threadId = getArray(attribute)[0]; break; case 'x-gm-thrid': map.threadId = getString(attribute); break; case 'x-gm-labels': // labels are mailbox names, modified UTF-7 unless UTF-8 is enabled map.labels = new Set(getArray(attribute).map(label => (connection ? decodePath(connection, label) : label))); break; case 'rfc822.size': map.size = getUint(attribute) || 0; break; case 'flags': map.flags = new Set(getArray(attribute)); break; // A server with nothing to report can answer ENVELOPE NIL or BODYSTRUCTURE NIL. // The field is left unset then: parsing NIL threw, and the whole message // disappeared from the FETCH result. case 'envelope': if (Array.isArray(attribute)) { map.envelope = parseEnvelope(attribute); } break; case 'bodystructure': if (Array.isArray(attribute)) { map.bodyStructure = parseBodystructure(attribute); } break; case 'internaldate': { let value = getString(attribute); let date = new Date(value); if (date.toString() === 'Invalid Date') { map.internalDate = value; } else { map.internalDate = date; } break; } default: { let match = key.match(/(body|binary)\[/i); if (match) { let partKey = key.replace(/^(body|binary)\[|]$/gi, ''); partKey = partKey.replace(/\.fields.*$/g, ''); let value = getBuffer(attribute); if (partKey === 'header') { map.headers = value; break; } if (!map.bodyParts) { map.bodyParts = new Map(); } map.bodyParts.set(partKey, value); recordOrigin(partKey); if (match[1].toLowerCase() === 'binary') { // The part arrived via FETCH BINARY (RFC 3516, FETCH side folded // into IMAP4rev2), so the server has already removed the // content-transfer-encoding - consumers must not decode it again. // Recorded from the actual response, not predicted from the // request, so it stays correct even if a server answers a BINARY // request with a BODY response or vice versa. if (!map.binaryParts) { map.binaryParts = new Set(); } map.binaryParts.add(partKey); } break; } break; } } } if (map.emailId || map.uid) { // define account unique ID for this email // normalize path to use ascii, so we would always get the same ID let path = mailbox.path; if (/[\u0080-\uffff]/.test(path)) { try { path = iconv_lite_1.default.encode(path, 'utf-7-imap').toString(); } catch { // ignore } } // Non-cryptographic identifier: the hash only derives a stable, compact account-unique // id from non-secret data (path:uidValidity:uid). No security property (collision or // preimage resistance, secrecy) is relied upon, so the fast default is MD5; a host whose // OpenSSL runs in FIPS mode has no MD5 and names another algorithm through the client // option, at the price of ids that differ from the default ones map.id = map.emailId || (0, node_crypto_1.createHash)(idHashAlgorithm || 'md5') .update([path, mailbox.uidValidity?.toString() || '', map.uid.toString()].join(':')) .digest('hex'); } if (partialOrigins) { // non-enumerable, so it stays out of logged and serialized fetch results Object.defineProperty(map, 'partialOrigins', { value: partialOrigins, writable: true, configurable: true }); } if (map.flags) { let flagColor = getFlagColor(map.flags); if (flagColor) { map.flagColor = flagColor; } } return map; } /** * Strips surrounding double quotes from a name string. * * @param name - Raw name string potentially wrapped in quotes * @returns Name with surrounding quotes removed */ function processName(name) { let value = (name || '').toString(); if (value.length > 2 && value.at(0) === '"' && value.at(-1) === '"') { value = value.slice(1, -1); } return value; } /** * Decodes an ENVELOPE text field for display: encoded words first, then the * surrounding quotes some servers leave in place. * * @param value - Raw field value from an ENVELOPE response * @returns Decoded, unquoted text */ function decodeText(value) { return processName(libmime_1.default.decodeWords(value)); } /** * Parses a raw IMAP ENVELOPE response into a structured envelope object. * * @param entry - Raw envelope data array from IMAP response * @returns Parsed envelope with date, subject, from, to, cc, bcc, messageId, etc. */ function parseEnvelope(entry) { let getStrValue = (obj) => { if (!obj) { return false; } if (typeof obj.value === 'string') { return obj.value; } if (Buffer.isBuffer(obj.value)) { return obj.value.toString(); } /* c8 ignore next */ // defensive: envelope tokens are always string/Buffer/NIL, never another type return obj.value; }; let processAddresses = function (list) { /* c8 ignore next 2 */ // defensive: processAddresses is only called with non-empty arrays, so the [] fallback is unreachable return [] .concat(list || []) .map(addr => { if (!addr) { // A NIL entry inside an address list: skip it instead of // throwing on the dereference and dropping the message return false; } let entry = addr; let name = decodeText(getStrValue(entry[0])); let mailbox = (getStrValue(entry[2]) || ''); let host = (getStrValue(entry[3]) || ''); if (!host) { // RFC 9051 7.5.2: a NIL host field marks RFC 5322 group syntax, it is not // an empty domain. A non-NIL mailbox then holds the group name phrase, a // NIL one closes the group. Joining the fields anyway would invent an // address that never appeared in the message, eg. "undisclosed-recipients@", // so surface the group name as a display name and leave the address empty. // End-of-group markers carry neither and the filter below drops them. // The mirror case, a NIL mailbox with a host, is left alone on purpose: // the grammar gives it no meaning, so a server sending it is simply // malformed rather than signalling anything we could act on. return { name: name || (mailbox && decodeText(mailbox)), address: '' }; } return { name, address: `${mailbox}@${host}` }; }) .filter((addr) => !!(addr && (addr.name || addr.address))); }, envelope = {}; if (entry[0] && entry[0].value) { let date = new Date(getStrValue(entry[0])); if (date.toString() === 'Invalid Date') { envelope.date = getStrValue(entry[0]); } else { envelope.date = date; } } if (entry[1] && entry[1].value) { envelope.subject = libmime_1.default.decodeWords(getStrValue(entry[1])); } if (Array.isArray(entry[2]) && entry[2].length) { envelope.from = processAddresses(entry[2]); } if (Array.isArray(entry[3]) && entry[3].length) { envelope.sender = processAddresses(entry[3]); } if (Array.isArray(entry[4]) && entry[4].length) { envelope.replyTo = processAddresses(entry[4]); } if (Array.isArray(entry[5]) && entry[5].length) { envelope.to = processAddresses(entry[5]); } if (Array.isArray(entry[6]) && entry[6].length) { envelope.cc = processAddresses(entry[6]); } if (Array.isArray(entry[7]) && entry[7].length) { envelope.bcc = processAddresses(entry[7]); } if (entry[8] && entry[8].value) { /* c8 ignore next */ // the guard ensures getStrValue is truthy here, so the '' fallback is unreachable envelope.inReplyTo = (getStrValue(entry[8]) || '').toString().trim(); } if (entry[9] && entry[9].value) { /* c8 ignore next */ // the guard ensures getStrValue is truthy here, so the '' fallback is unreachable envelope.messageId = (getStrValue(entry[9]) || '').toString().trim(); } return envelope; } /** * Parses structured MIME parameter arrays (including RFC 2231 continuations) * into a flat key-value object. * * @param arr - Raw parameter array from BODYSTRUCTURE response * @returns Key-value object of decoded parameters */ function getStructuredParams(arr) { let key; // Continuation parts are collected as {charset, values} objects before being joined // back into strings, so the map holds both shapes while it is being built let params = {}; // BODYSTRUCTURE parameters come as flat key/value pairs: [key1, val1, key2, val2, ...] [].concat(arr || []).forEach((val, j) => { if (j % 2) { // Parameter names are server-controlled. The load-bearing check is the one in // the continuation pass below, where the value is an object; here the value is // always a string, which the __proto__ setter ignores anyway. if (!isUnsafeKey(key)) { params[key] = libmime_1.default.decodeWords(((val && val.value) || '').toString()); } } else { key = ((val && val.value) || '').toString().toLowerCase(); } }); // Detect RFC 2231 encoded filenames that were placed in the plain 'filename' param // instead of 'filename*'. The pattern charset'language'encoded_value indicates encoding. if (params.filename && !params['filename*'] && /^[a-z\-_0-9]+'[a-z]*'[^'\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uffff]+/.test(params.filename)) { // seems like encoded value let [encoding, , encodedValue] = params.filename.split("'"); if ((0, charsets_js_1.resolveCharset)(encoding)) { params['filename*'] = `${encoding}''${encodedValue}`; } } // RFC 2231 parameter continuations: parameters like filename*0, filename*1, etc. // are split parts of a single value. Parameters ending with '*' contain charset info. // This pass collects continuation parts and groups them by their base key name. Object.keys(params).forEach(key => { let actualKey; let nr; let value; // Match keys ending with *N or *N* (where N is the continuation index) let match = key.match(/\*((\d+)\*?)?$/); if (!match) { // nothing to do here, does not seem like a continuation param return; } actualKey = key.substring(0, match.index).toLowerCase(); nr = Number(match[2]) || 0; if (isUnsafeKey(actualKey)) { // A continuation key like "__proto__*0*" would group under "__proto__": // params['__proto__'] resolves to Object.prototype, so the grouping // writes below would mutate it (process-wide pollution). Drop the part. delete params[key]; return; } if (!params[actualKey] || typeof params[actualKey] !== 'object') { params[actualKey] = { charset: false, values: [] }; } value = params[key]; // The first segment (*0*) may contain charset and language: charset'language'value if (nr === 0 && match[0].at(-1) === '*' && (match = value.match(/^([^']*)'[^']*'(.*)$/))) { params[actualKey].charset = match[1] || 'utf-8'; value = match[2]; } params[actualKey].values.push({ nr, value }); // remove the old reference delete params[key]; }); // Reassemble split RFC 2231 strings by sorting continuation parts and joining them. // For charset-encoded values, convert URL-encoded (%XX) sequences to MIME quoted-printable // format (=?charset?Q?...?=) so libmime.decodeWords can decode them to Unicode. Object.keys(params).forEach(key => { let value; if (params[key] && Array.isArray(params[key].values)) { value = params[key].values .sort((a, b) => a.nr - b.nr) .map((val) => (val && val.value) || '') .join(''); if (params[key].charset) { // Convert URL encoding (%AB) to MIME quoted-printable (=AB) by: // 1. Escaping QP-special chars (=, ?, _, space) as %XX // 2. Replacing all '%' with '=' to switch from URL encoding to QP encoding // 3. Wrapping in =?charset?Q?...?= for libmime to decode params[key] = libmime_1.default.decodeWords('=?' + params[key].charset + '?Q?' + value // fix invalidly encoded chars .replace(/[=?_\s]/g, s => { if (s === ' ') { return '_'; } let c = s.charCodeAt(0).toString(16); return '%' + (c.length < 2 ? '0' : '') + c; }) // change from urlencoding to percent encoding .replace(/%/g, '=') + '?='); } else { params[key] = libmime_1.default.decodeWords(value); } } }); return params; } /** * Parses a raw IMAP BODYSTRUCTURE response into a structured tree of body parts. * * @param entry - Raw BODYSTRUCTURE data array from IMAP response * @returns Parsed body structure tree with part numbers, types, parameters, and child nodes */ function parseBodystructure(entry) { // Recursively walks the BODYSTRUCTURE tree, building MIME part numbers. // Part numbers follow the IMAP dot-notation: "1", "1.1", "2.3", etc. // The root multipart has no part number; its children start at 1. let walk = (node, path) => { path = path || []; let curNode = {}, i = 0, part = 0; // Build the dot-separated part number from the path array (e.g., [1,2] -> "1.2") if (path.length) { curNode.part = path.join('.'); } // multipart: first elements are arrays (child body parts), followed by the subtype string if (Array.isArray(node[0])) { curNode.childNodes = []; // Each child array is a nested body part; increment part counter for each while (Array.isArray(node[i])) { curNode.childNodes.push(walk(node[i], path.concat(++part))); i++; } // multipart type curNode.type = 'multipart/' + ((node[i++] || {}).value || '').toString().toLowerCase(); // extension data (not available for BODY requests) // body parameter parenthesized list if (i < node.length) { if (node[i]) { curNode.parameters = getStructuredParams(node[i]); } i++; } } else { // content type curNode.type = [ ((node[i++] || {}).value || '').toString().toLowerCase(), ((node[i++] || {}).value || '').toString().toLowerCase() ].join('/'); // body parameter parenthesized list if (node[i]) { curNode.parameters = getStructuredParams(node[i]); } i++; // id if (node[i]) { curNode.id = (node[i].value || '').toString(); } i++; // description if (node[i]) { curNode.description = (node[i].value || '').toString(); } i++; // encoding if (node[i]) { curNode.encoding = (node[i].value || '').toString().toLowerCase(); } i++; // size if (node[i]) { curNode.size = Number(node[i].value || 0) || 0; } i++; if (curNode.type === 'message/rfc822') { // message/rfc822 is special in IMAP BODYSTRUCTURE: after the standard // 7 fields, it includes an embedded envelope, a nested bodystructure, // and a line count for the encapsulated message. // envelope of the encapsulated message if (node[i]) { /* c8 ignore next */ // node[i] is truthy inside this guard, so the [] fallback is unreachable curNode.envelope = parseEnvelope([].concat(node[i] || [])); } i++; if (node[i]) { curNode.childNodes = [ // The nested bodystructure reuses the same path (not path+1) because // the encapsulated message shares the part number with its wrapper. // Distinction is via suffixes: path.MIME = wrapper headers, // path.HEADER = encapsulated message headers. walk(node[i], path) ]; } i++; // line count if (node[i]) { curNode.lineCount = Number(node[i].value || 0) || 0; } i++; } if (/^text\//.test(curNode.type)) { // Per RFC 3501, text/* parts include an additional line count field after size. // However, some servers omit this field, producing 11 elements instead of 12+. // NB! some less known servers do not include the line count value // length should be 12+ if (node.length === 11 && Array.isArray(node[i + 1]) && !Array.isArray(node[i + 2])) { // invalid structure, disposition params are shifted - skip the line count } else { // correct structure, line count number is provided if (node[i]) { curNode.lineCount = Number(node[i].value || 0) || 0; } i++; } } // extension data (not available for BODY requests) // md5 if (i < node.length) { if (node[i]) { curNode.md5 = (node[i].value || '').toString().toLowerCase(); } i++; } } // the following are shared extension values (for both multipart and non-multipart parts) // not available for BODY requests // body disposition if (i < node.length) { let disposition = node[i]; if (Array.isArray(disposition) && disposition.length) { curNode.disposition = ((disposition[0] && disposition[0].value) || '').toString().toLowerCase(); if (Array.isArray(disposition[1])) { curNode.dispositionParameters = getStructuredParams(disposition[1]); } } i++; } // body language if (i < node.length) { if (node[i]) { /* c8 ignore next */ // node[i] is truthy inside this guard, so the [] fallback is unreachable curNode.language = [].concat(node[i] || []).map(val => ((val && val.value) || '').toString().toLowerCase()); } i++; } // body location // NB! defined as a "string list" in RFC3501 but replaced in errata document with "string" // Errata: http://www.rfc-editor.org/errata_search.php?rfc=3501 if (i < node.length) { if (node[i]) { curNode.location = (node[i].value || '').toString(); } } return curNode; }; return walk(entry); } /** * Checks if a value is a Date object. * * @param obj - Value to check * @returns True if the value is a Date object */ function isDate(obj) { return Object.prototype.toString.call(obj) === '[object Date]'; } /** * Converts a value to a valid Date object, or returns null. * * @param value - Date object or date string to convert * @returns Valid Date object, or null if conversion fails */ function toValidDate(value) { if (!value) { return null; } if (typeof value === 'string') { value = new Date(value); } if (!isDate(value) || value.toString() === 'Invalid Date') { return null; } return value; } /** * Formats a date value into IMAP date format (DD-Mon-YYYY). * * @param value - Date to format * @returns Formatted date string, or undefined if invalid */ function formatDate(value) { let date = toValidDate(value); if (!date) { return; } let dateParts = date.toISOString().substring(0, 10).split('-'); dateParts.reverse(); let months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; dateParts[1] = months[Number(dateParts[1]) - 1]; return dateParts.join('-'); } /** * Formats a date value into IMAP date-time format (DD-Mon-YYYY HH:MM:SS +0000). * * @param value - Date to format * @returns Formatted date-time string, or undefined if invalid */ function formatDateTime(value) { let date = toValidDate(value); if (!date) { return; } let dateStr = formatDate(date).replace(/^0/, ' '); //starts with date-day-fixed with leading 0 replaced by SP let timeStr = date.toISOString().substring(11, 19); return `${dateStr} ${timeStr} +0000`; } // the memoized ATOM-CHAR set of RFC 9051 section 9 const atomChars = imap_formal_syntax_js_1.default['ATOM-CHAR']; /** * Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent) and for * values that are not valid flags (keywords must be atoms), and capitalizes system flags properly. * * @param flag - Flag string to normalize * @returns Normalized flag string, or false if the flag cannot be set */ function formatFlag(flag) { // RFC 9051 section 9: flag-keyword is an atom and flag-extension is "\\" atom, the same check // the compiler uses to decide what it can send unquoted let atom = flag.charAt(0) === '\\' ? flag.slice(1) : flag; if (!atom || imap_formal_syntax_js_1.default.verify(atom, atomChars()) >= 0) { return false; } switch (flag.toLowerCase()) { case '\\recent': // can not set or remove return false; case '\\seen': case '\\answered': case '\\flagged': case '\\deleted': case '\\draft': // normalize capitalization (e.g., "\\seen" -> "\\Seen") return flag.toLowerCase().replace(/^\\./, c => c.toUpperCase()); } return flag; } /** * Checks if a flag can be used in the given mailbox based on permanent flags. * * @param mailbox - Mailbox object with permanentFlags * @param flag - Flag to check * @returns True if the flag is allowed */ function canUseFlag(mailbox, flag) { return !mailbox || !mailbox.permanentFlags || mailbox.permanentFlags.has('\\*') || mailbox.permanentFlags.has(flag); } /** * Checks that a value is a valid IMAP sequence number or UID: a non-zero * 32-bit unsigned integer (nz-number in the RFC 9051 grammar). Guards range * expansion against untrusted server input such as 'Infinity' or '0:*'. * * @param value - Value to check * @returns True if the value is a valid sequence number/UID */ function isValidSequenceValue(value) { return Number.isSafeInteger(value) && value > 0 && value <= 0xffffffff; } /** * Checks that an untrusted response value is a pure decimal digit run no longer than * the given bound. * * `!isNaN(value)` is not usable for this: it also passes '1e5', ' 12 ', '0x10' and * 'Infinity'. BigInt() throws on all of them and Number() silently returns a value the * grammar never allowed, so both are wrong in a response handler that is only trying to * read one field. The length bound is checked before the pattern so an arbitrarily long * digit run is rejected without any conversion work. * * @param value - Raw value from the response. * @param maxDigits - Maximum number of digits accepted. * @returns True if the value is a decimal string within the bound. */ function isDecimalString(value, maxDigits) { return typeof value === 'string' && value.length > 0 && value.length <= maxDigits && /^[0-9]+$/.test(value); } /** * Checks whether a server-supplied string is unsafe to use as a key on a plain object. * Assigning "__proto__" writes through the prototype setter instead of creating an own * property, and reading "constructor" or "prototype" resolves to an inherited member. * * @param key - Candidate key from a server response. * @returns True if the key must not be used. */ function isUnsafeKey(key) { return UNSAFE_OBJECT_KEYS.has(key); } /** * Reads a parsed attribute list of atoms or strings (a flag list, a capability list) into * an array of strings. Any element can be a parsed NIL, and the list itself can be NIL, * so both levels are guarded here rather than at each call site. A literal (a Gmail label * the server could not send quoted) arrives as a Buffer and is decoded as UTF-8. * * @param list - Parsed attribute list from a response. * @returns The string values, in order, with unusable entries dropped. */ function getStringList(list) { if (!Array.isArray(list)) { return []; } let strings = []; for (let entry of list) { let value = getStringValue(entry); if (value) { strings.push(value); } } return strings; } /** * Reads the text of a string value from a response. A server may send any string as a literal, * which arrives as a Buffer and is decoded as UTF-8. * * @param attribute - Parsed attribute from a response * @returns The string, or undefined when the attribute holds no string (NIL, a list) */ function getStringValue(attribute) { let value = attribute && attribute.value; if (Buffer.isBuffer(value)) { return value.toString(); } return typeof value === 'string' ? value : undefined; } /** * Parses an untrusted decimal value from a server response into a BigInt. * * @param value - Raw value from the response. * @param maxDigits - Maximum number of digits accepted. Defaults to MAX_NUMBER64_DIGITS. * @returns The parsed value, or false when it is not usable. */ function parseBigIntValue(value, maxDigits) { if (!isDecimalString(value, maxDigits || MAX_NUMBER64_DIGITS)) { return false; } return BigInt(value); } /** * Parses an untrusted decimal value from a server response into a Number. Values beyond * the safe integer range are rejected rather than rounded: a silently rounded count or * UID corrupts every range computation derived from it. * * @param value - Raw value from the response. * @param maxDigits - Maximum number of digits accepted. Defaults to MAX_NUMBER64_DIGITS. * @returns The parsed value, or false when it is not usable. */ function parseUintValue(value, maxDigits) { if (!isDecimalString(value, maxDigits || MAX_NUMBER64_DIGITS)) { return false; } let num = Number(value); return Number.isSafeInteger(num) ? num : false; } /** * Parses an IMAP sequence set string (e.g. "1:3,5,9:7") into its entries, each as the pair of * endpoints in the order written: a reversed "9:7" stays reversed, and iterateRange() walks it * downwards. Entries with an endpoint that is not a valid nz-number are skipped - the input may * come from an untrusted server, and 'Infinity' or similar garbage would otherwise loop without * bound. * * @param range - IMAP sequence set string, or whatever the response parser produced for it * @returns The validated entries */ function parseRangeEntries(range) { let entries = []; // Callers pass whatever the response parser produced for the sequence set, and a // malformed response can leave that as `false` (e.g. a VANISHED response carrying // only the (EARLIER) tag). Nothing to expand then, and throwing here would abort // the handler for the rest of the response. if (typeof range !== 'string') { return entries; } for (let entry of range.split(',')) { entry = entry.trim(); let colon = entry.indexOf(':'); if (colon < 0) { let value = Number(entry); if (isValidSequenceValue(value)) { entries.push([value, value]); } continue; } let first = Number(entry.substring(0, colon)); let second = Number(entry.slice(colon + 1)); if (isValidSequenceValue(first) && isValidSequenceValue(second)) { entries.push([first, second]); } } return entries; } /** * Walks an IMAP sequence range string (e.g. "1:3,5,7:9") one number at a time, without * materializing it. A single "1:16777216" is a few bytes on the wire but sixteen million * entries expanded, so a consumer that only needs to visit each number (the VANISHED handler) * iterates instead of calling expandRange(). * * The same bound as expandRange() applies: at most EXPANDED_RANGE_LIMIT numbers in total are * produced, over all entries of the set. * * @param range - IMAP sequence range string * @returns The sequence numbers, in the order the set lists them */ function* iterateRange(range) { let count = 0; for (let [first, second] of parseRangeEntries(range)) { let step = first <= second ? 1 : -1; for (let i = first; count < exports.EXPANDED_RANGE_LIMIT; i += step) { yield i; count++; if (i === second) { break; } } if (count >= exports.EXPANDED_RANGE_LIMIT) { break; } } } /** * Counts the numbers iterateRange() produces for a sequence range string, without walking it. * * @param range - IMAP sequence range string * @returns The number of entries, capped at EXPANDED_RANGE_LIMIT like the expansion itself */ function countRange(range) { let count = 0; for (let [first, second] of parseRangeEntries(range)) { count += Math.abs(second - first) + 1; if (count >= exports.EXPANDED_RANGE_LIMIT) { return exports.EXPANDED_RANGE_LIMIT; } } return count; } /** * Expands an IMAP sequence range string (e.g. "1:3,5,7:9") into an array of numbers. * * Entries with endpoints that are not valid nz-numbers are skipped - the input * may come from an untrusted server, and 'Infinity' or similar garbage would * otherwise loop without bound. The whole set is expanded to at most * EXPANDED_RANGE_LIMIT entries in total: legitimate responses never reach the limit * (the mailbox would need that many messages), while hostile input is cut off * instead of exhausting memory. The total is capped, not just each range - * otherwise "1:16777216,1:16777216,..." would multiply the per-range bound by an * unbounded number of ranges. * * @param range - IMAP sequence range string * @returns Array of expanded sequence numbers */ function expandRange(range) { return Array.from(iterateRange(range)); } /** * Returns a stream decoder for the given charset. Uses a special Japanese * charset decoder for JIS/ISO-2022-JP, otherwise delegates to iconv-lite. * * @param charset - Character set name. Defaults to 'ascii'. * @param maxBytes - Bound for the bytes the decoder may buffer. Only * relevant for the Japanese decoder, which must buffer its whole input before * it can decode: without the bound a server could defeat a caller's maxBytes * download limit simply by labelling the part with a Japanese charset. * @returns A stream decoder (Transform stream) for the charset */ function getDecoder(charset, maxBytes) { charset = (charset || 'ascii').toString().trim().toLowerCase(); if (/^jis|^iso-?2022-?jp|^euc-?jp/.test(charset)) { // special case not supported by iconv-lite return new jp_decoder_js_1.JPDecoder(charset, maxBytes); } return iconv_lite_1.default.decodeStream(charset); } /** * Packs an array of message sequence numbers into a compact IMAP range string * (e.g. [1,2,3,5,7,8] becomes "1:3,5,7:8"). * * @param list - Sequence number or array of sequence numbers * @returns Packed IMAP sequence range string */ function packMessageRange(list) { let items; if (!Array.isArray(list)) { items = [].concat(list || []); } else { items = list; } if (!items.length) { return ''; } // Deduplicate before sorting so that repeated values do not produce // overlapping/non-canonical tokens (e.g. [1,1,2,3] -> "1:3", not "1,1:3"). items = Array.from(new Set(items)).sort((a, b) => a - b); // One forward pass: a run of consecutive values is written out as soon as it ends. The runs // used to be built with unshift(), which copies the array on every call, so packing a large // SEARCH result (a server-chosen size) took seconds for 200k UIDs and about a minute for 1M, // all of it on the event loop. let parts = []; let start = items[0]; let prev = start; for (let i = 1; i <= items.length; i++) { let value = items[i]; if (i < items.length && value === prev + 1) { prev = value; continue; } parts.push(start === prev ? String(start) : start + ':' + prev); start = prev = value; } return parts.join(','); }