UNPKG

imapflow

Version:

IMAP Client for Node

260 lines (225 loc) 12.8 kB
'use strict'; const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js'); /** * Selects or examines a mailbox, making it the current mailbox for subsequent operations. * * @param {Object} connection - IMAP connection instance * @param {string} path - Mailbox path to select * @param {Object} [options] - Select options * @param {boolean} [options.readOnly] - If true, use EXAMINE instead of SELECT (read-only access) * @param {string} [options.changedSince] - QRESYNC modseq value to fetch changes since * @param {BigInt} [options.uidValidity] - QRESYNC UID validity value * @returns {Promise<Object|undefined>} Mailbox info object with path, flags, exists, uidNext, uidValidity, highestModseq, etc., or undefined if preconditions not met * @throws {Error} If the SELECT/EXAMINE command fails */ module.exports = async (connection, path, options) => { if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) { // nothing to do here return; } options = options || {}; path = normalizePath(connection, path); // Ensure we have folder metadata (delimiter, flags, specialUse) by running LIST if needed. // This is cached in connection.folders to avoid repeated LIST calls. if (!connection.folders.has(path)) { let folders = await connection.run('LIST', '', path); if (!folders) { throw new Error('Failed to fetch folders'); } folders.forEach(folder => { connection.folders.set(folder.path, folder); }); } let folderListData = connection.folders.has(path) ? connection.folders.get(path) : false; let response; try { let map = { path }; if (folderListData) { ['delimiter', 'specialUse', 'subscribed', 'listed'].forEach(key => { if (folderListData[key]) { map[key] = folderListData[key]; } }); } // QRESYNC (RFC 7162): allows efficient mailbox resynchronization by sending // the last known UIDVALIDITY and HIGHESTMODSEQ. Server responds with only // the changes (new flags, expunged UIDs) since that point. let extraArgs = []; if (connection.enabled.has('QRESYNC') && options.changedSince && options.uidValidity) { extraArgs.push([ { type: 'ATOM', value: 'QRESYNC' }, [ { type: 'ATOM', value: options.uidValidity?.toString() }, { type: 'ATOM', value: options.changedSince.toString() } ] ]); map.qresync = true; } let encodedPath = encodePath(connection, path); // SELECT opens the mailbox read-write; EXAMINE opens it read-only. // Path encoding: if the encoded path contains '&' (UTF-7 encoding marker), // send as quoted STRING to avoid parser issues with the ampersand. let selectCommand = { command: !options.readOnly ? 'SELECT' : 'EXAMINE', arguments: [{ type: encodedPath.indexOf('&') >= 0 ? 'STRING' : 'ATOM', value: encodedPath }].concat(extraArgs || []) }; response = await connection.exec(selectCommand.command, selectCommand.arguments, { untagged: { // Untagged OK responses carry response codes in brackets, e.g.: // * OK [UIDVALIDITY 1234] UIDs valid // * OK [PERMANENTFLAGS (\Seen \Answered \*)] Flags permitted // The section array holds the parsed bracket contents: section[0] is the // key (e.g., "UIDVALIDITY"), section[1] is the value or list. OK: async untagged => { if (!untagged.attributes || !untagged.attributes.length) { return; } let section = !untagged.attributes[0].value && untagged.attributes[0].section; // Handle response codes with a key-value pair (section has 2+ elements) if (section && section.length > 1 && section[0].type === 'ATOM' && typeof section[0].value === 'string') { let key = section[0].value.toLowerCase(); let value; // Value can be a single string or a list of strings (e.g., PERMANENTFLAGS) if (typeof section[1].value === 'string') { value = section[1].value; } else if (Array.isArray(section[1])) { value = section[1].map(entry => (typeof entry.value === 'string' ? entry.value : false)).filter(entry => entry); } switch (key) { // CONDSTORE (RFC 7162): highest mod-sequence value for the mailbox. // Used for incremental sync -- clients compare against their cached // value to detect changes. Stored as BigInt since modseq values // can exceed Number.MAX_SAFE_INTEGER. case 'highestmodseq': key = 'highestModseq'; if (/^[0-9]+$/.test(value)) { value = BigInt(value); } break; // OBJECTID (RFC 8474): server-assigned unique mailbox identifier. // Unlike path, this ID survives renames. Value comes as a // parenthesized list, so extract the first (only) element. case 'mailboxid': key = 'mailboxId'; if (Array.isArray(value) && value.length) { value = value[0]; } break; // Flags that the client can change permanently on messages in // this mailbox. Includes \* if the server allows custom flags. case 'permanentflags': key = 'permanentFlags'; value = new Set(value); break; // The next UID that will be assigned to a new message in this // mailbox. Useful for detecting new arrivals. case 'uidnext': key = 'uidNext'; value = Number(value); break; // Unique identifier validity value. If this changes between // sessions, all previously cached UIDs are invalid and the // client must re-sync from scratch. case 'uidvalidity': key = 'uidValidity'; if (/^[0-9]+$/.test(value)) { value = BigInt(value); } break; } map[key] = value; } // Handle response codes with only a keyword (no value), e.g., [NOMODSEQ] if (section && section.length === 1 && section[0].type === 'ATOM' && typeof section[0].value === 'string') { let key = section[0].value.toLowerCase(); switch (key) { // NOMODSEQ means the mailbox does not support mod-sequences. // CONDSTORE/QRESYNC features are unavailable for this mailbox. case 'nomodseq': key = 'noModseq'; map[key] = true; break; } } }, // Untagged FLAGS response lists all flags defined for this mailbox // (both system flags and custom flags). Example: * FLAGS (\Seen \Answered \Flagged) FLAGS: async untagged => { if (!untagged.attributes || (!untagged.attributes.length && Array.isArray(untagged.attributes[0]))) { return; } let flags = untagged.attributes[0].map(flag => (typeof flag.value === 'string' ? flag.value : false)).filter(flag => flag); map.flags = new Set(flags); }, // Untagged EXISTS response: "* <count> EXISTS" tells us the total number // of messages in the mailbox. The count is in the command field (numeric prefix). EXISTS: async untagged => { let num = Number(untagged.command); if (isNaN(num)) { return false; } map.exists = num; }, // VANISHED responses (QRESYNC): server reports UIDs that have been expunged // since the client's last known state. Only received when QRESYNC was requested. // A dummy mailbox object is passed because the mailbox isn't officially open yet. VANISHED: async untagged => { await connection.untaggedVanished(untagged, { path, uidNext: false, uidValidity: false }); }, // Untagged FETCH during SELECT/EXAMINE: only occurs with QRESYNC, delivering // updated flags for messages that changed since the client's last modseq. FETCH: async untagged => { await connection.untaggedFetch(untagged, { path, uidNext: false, uidValidity: false }); } } }); // The tagged OK response to SELECT/EXAMINE includes [READ-ONLY] or [READ-WRITE] // in its response code, indicating the access mode the server granted. let section = !response.response.attributes[0].value && response.response.attributes[0].section; if (section && section.length && section[0].type === 'ATOM' && typeof section[0].value === 'string') { map.readOnly = section[0].value.toUpperCase() === 'READ-ONLY'; } // Validate QRESYNC preconditions (RFC 7162 Section 3.2.5): // QRESYNC results are only valid if UIDVALIDITY matches, HIGHESTMODSEQ is // present, and the mailbox supports mod-sequences. If any condition fails, // the client cannot trust the incremental updates and must do a full resync. if (map.qresync && (options.uidValidity !== map.uidValidity || !map.highestModseq || map.noModseq)) { map.qresync = false; } // Transition mailbox state: save previous mailbox reference, temporarily // clear it, then emit events and set the new mailbox. let currentMailbox = connection.mailbox; connection.mailbox = false; // Emit mailboxClose if we're switching from a different mailbox. // Re-selecting the same mailbox (e.g., for resync) does not trigger close/open. if (currentMailbox && currentMailbox.path !== path) { connection.emit('mailboxClose', currentMailbox); } connection.mailbox = map; // Save the SELECT command for potential re-use (e.g., NOOP fallback polling // re-issues the SELECT to detect changes on servers without IDLE support). connection.currentSelectCommand = selectCommand; connection.state = connection.states.SELECTED; if (!currentMailbox || currentMailbox.path !== path) { connection.emit('mailboxOpen', connection.mailbox); } response.next(); return map; } catch (err) { await enhanceCommandError(err); // If SELECT/EXAMINE fails while a mailbox was already selected, we must // reset to AUTHENTICATED state since the server has implicitly deselected // the previous mailbox on failure (RFC 3501 Section 6.3.1). if (connection.state === connection.states.SELECTED) { let currentMailbox = connection.mailbox; connection.mailbox = false; connection.currentSelectCommand = false; connection.state = connection.states.AUTHENTICATED; if (currentMailbox) { connection.emit('mailboxClose', currentMailbox); } } connection.log.warn({ err, cid: connection.id }); throw err; } };