"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = fetch; const tools_js_1 = require("../tools.js"); const errors_js_1 = require("../errors.js"); /** * Fetches emails from the server. * * @param connection - IMAP connection instance * @param range - Message sequence number or UID range * @param query - Fetch query specifying which data to retrieve (e.g., flags, envelope, bodyStructure, headers, source, bodyParts) * @param options - Fetch options * @returns Object with message count and list, or undefined when the range is empty * @throws NoMailboxSelected when not in SELECTED state (NoConnection once the connection is gone) */ async function fetch(connection, range, query, options) { let mailbox = (0, tools_js_1.getSelectedMailbox)(connection); if (!mailbox) { // queued behind a CLOSE or a failed SELECT: rejecting keeps fetch() from yielding // nothing as if the mailbox were empty throw connection.createNoMailboxError('FETCH'); } if (!range) { // nothing to do here return; } options = options || {}; // Use BINARY extension for fetching if supported and requested, otherwise fall back to BODY. // RFC 9051 folds the FETCH side of the BINARY extension into base IMAP4rev2, so an active // rev2 session can use it even without the BINARY capability token (the APPEND side is NOT // folded in and stays gated on the token in append.ts) const canUseBinary = connection.capabilities.has('BINARY') || (0, tools_js_1.isRev2Active)(connection); const commandKey = canUseBinary && options.binary && !connection.disableBinary ? 'BINARY' : 'BODY'; // Retry logic for ETHROTTLE errors (server rate limiting) with exponential backoff. // Every pass returns or throws: the last throttled attempt throws instead of retrying. const maxRetries = 4; const baseDelay = 1000; // Start with 1 second delay // The highest UID (sequence number for a plain FETCH) handed to the streaming consumer. A // retried FETCH answers with every message again, so a retry skips up to it: servers answer // in ascending order, which keeps this to one comparison per row rather than a set of every // row delivered. The consumer was otherwise given the rows before the throttle twice. let maxDelivered = 0; for (let retryCount = 0;; retryCount++) { let messages = { count: 0, list: [] }; // The first error the onUntaggedFetch consumer reported through next(err), or the row // limit being reached. Errors thrown by untagged handlers are only logged by the // connection, so it is kept here and fails the command once the FETCH completes; later // messages are no longer handed to the consumer or collected. let consumerError = null; let response; try { /* c8 ignore next */ // range is guaranteed truthy by the early-return guard above, so the '*' fallback is unreachable let attributes = [{ type: 'SEQUENCE', value: (range || '*').toString() }]; let queryStructure = []; // Helper to build BODY.PEEK[section] or BINARY.PEEK[section] atoms. // PEEK avoids marking messages as \Seen. Section identifies what to fetch (HEADER, specific part, etc.) // Partial is an optional byte range [start, maxLength]. let setBodyPeek = (attributes, partial) => { let section = [].concat(attributes || []); // BINARY may only address the empty section or a numeric part specifier // (RFC 3516 / RFC 9051 section-binary) - HEADER, HEADER.FIELDS, TEXT and // n.MIME are invalid after BINARY and must stay BODY fetches let first = section[0]; let binaryAddressable = !section.length || (section.length === 1 && typeof first.value === 'string' && /^\d+(\.\d+)*$/.test(first.value)); let bodyPeek = { type: 'ATOM', value: `${binaryAddressable ? commandKey : 'BODY'}.PEEK`, section: section, partial }; queryStructure.push(bodyPeek); }; // The ALL, FAST and FULL macros may only be sent on their own, never in a list with other // items (RFC 3501 section 9), and UID is always in the list, so they are expanded into // the items they stand for. FULL is expanded to BODYSTRUCTURE rather than the // non-extensible BODY, as documented for the full option. let full = !!query.full; let all = !!query.all || full; let fast = !!query.fast || all; let items = { flags: query.flags || fast, internalDate: query.internalDate || fast, size: query.size || fast, envelope: query.envelope || all, bodyStructure: query.bodyStructure || full }; // standard data items map directly to IMAP atoms if (query.uid) { queryStructure.push({ type: 'ATOM', value: 'UID' }); } ['flags', 'bodyStructure', 'envelope', 'internalDate'].forEach(key => { if (items[key]) { queryStructure.push({ type: 'ATOM', value: key.toUpperCase() }); } }); if (items.size) { queryStructure.push({ type: 'ATOM', value: 'RFC822.SIZE' }); } // Fetch full message source, optionally with byte range (start/maxLength) if (query.source) { let partial; if (typeof query.source === 'object' && (query.source.start || query.source.maxLength)) { partial = [Number(query.source.start) || 0]; if (query.source.maxLength && !isNaN(query.source.maxLength)) { partial.push(Number(query.source.maxLength)); } } setBodyPeek(null, partial); } // Always request a unique email ID for message deduplication. // Prefer OBJECTID (RFC 8474) over Gmail's X-GM-MSGID extension. if (connection.capabilities.has('OBJECTID')) { queryStructure.push({ type: 'ATOM', value: 'EMAILID' }); } else if (connection.capabilities.has('X-GM-EXT-1')) { queryStructure.push({ type: 'ATOM', value: 'X-GM-MSGID' }); } // Thread ID: OBJECTID's THREADID or Gmail's X-GM-THRID if (query.threadId) { if (connection.capabilities.has('OBJECTID')) { queryStructure.push({ type: 'ATOM', value: 'THREADID' }); } else if (connection.capabilities.has('X-GM-EXT-1')) { queryStructure.push({ type: 'ATOM', value: 'X-GM-THRID' }); } } // Gmail labels are only available with X-GM-EXT-1 extension if (query.labels) { if (connection.capabilities.has('X-GM-EXT-1')) { queryStructure.push({ type: 'ATOM', value: 'X-GM-LABELS' }); } } // always ask for modseq if possible if (connection.enabled.has('CONDSTORE') && !mailbox.noModseq) { queryStructure.push({ type: 'ATOM', value: 'MODSEQ' }); } // Always include UID in the response even if not explicitly requested, // since we use it internally for message identification and tracking if (!query.uid) { queryStructure.push({ type: 'ATOM', value: 'UID' }); } // Headers: fetch all headers or only specific ones via HEADER.FIELDS if (query.headers) { if (Array.isArray(query.headers)) { setBodyPeek([{ type: 'ATOM', value: 'HEADER.FIELDS' }, query.headers.map(header => ({ type: 'ATOM', value: header }))]); } else { setBodyPeek({ type: 'ATOM', value: 'HEADER' }); } } // Fetch specific body parts by MIME part number (e.g., "1", "1.2", "2.MIME") // Each part can optionally include a byte range (start/maxLength) if (query.bodyParts && query.bodyParts.length) { query.bodyParts.forEach(part => { if (!part) { return; } let key; let partial; if (typeof part === 'object') { if (!part.key || typeof part.key !== 'string') { return; } key = part.key.toUpperCase(); if (part.start || part.maxLength) { partial = [Number(part.start) || 0]; if (part.maxLength && !isNaN(part.maxLength)) { partial.push(Number(part.maxLength)); } } } else if (typeof part === 'string') { key = part.toUpperCase(); } else { return; } setBodyPeek({ type: 'ATOM', value: key }, partial); }); } // IMAP requires a single item to not be wrapped in parentheses, but // multiple items must be in a list. If only one item, unwrap the array. let queryAttribute = queryStructure; if (queryStructure.length === 1) { queryAttribute = queryStructure.pop(); } attributes.push(queryAttribute); // CONDSTORE extension: only fetch messages with modseq higher than the given value. // QRESYNC adds VANISHED to also get expunged UIDs since last sync. if (options.changedSince && connection.enabled.has('CONDSTORE') && !mailbox.noModseq) { let changedSinceArgs = [ { type: 'ATOM', value: 'CHANGEDSINCE' }, { type: 'ATOM', value: options.changedSince.toString() } ]; if (options.uid && connection.enabled.has('QRESYNC')) { changedSinceArgs.push({ type: 'ATOM', value: 'VANISHED' }); } attributes.push(changedSinceArgs); } response = await connection.exec(options.uid ? 'UID FETCH' : 'FETCH', attributes, { untagged: { // Each matching message triggers an untagged FETCH response. // If onUntaggedFetch callback is provided, stream messages to it one by one // (useful for large result sets). Otherwise, collect all into messages.list. FETCH: async (untagged) => { messages.count++; if (consumerError) { return; } let formatted = await (0, tools_js_1.formatMessageResponse)(untagged, mailbox, connection.idHashAlgorithm, connection); if (typeof options.onUntaggedFetch === 'function') { /* c8 ignore next */ // a UID FETCH row without its UID is a non-compliant server, so the seq fallback is not exercised let key = options.uid ? formatted.uid || formatted.seq : formatted.seq; if (retryCount && key <= maxDelivered) { return; } maxDelivered = Math.max(maxDelivered, key); try { await new Promise((resolve, reject) => { options.onUntaggedFetch(formatted, err => { if (err) { reject(err); } else { resolve(); } }); }); } catch (err) { consumerError = err; } } else if (messages.list.length >= tools_js_1.MAX_COLLECTED_RESPONSES) { // The rows are the server's to send and the memory holding them is // this process's, see MAX_COLLECTED_RESPONSES consumerError = (0, errors_js_1.createImapError)(`FETCH returned more than ${tools_js_1.MAX_COLLECTED_RESPONSES} messages`, 'TooManyResponses'); } else { messages.list.push(formatted); } } } }); response.next(); if (consumerError) { throw consumerError; } return messages; } catch (err) { // The last throttled attempt falls through and throws, so running out of retries // is never mistaken for an empty result if (err.code === 'ETHROTTLE' && retryCount < maxRetries - 1) { // Server returned a throttle error (rate limiting). Retry with exponential backoff. // Delay doubles each retry: 1s, 2s, 4s. // If server provides a throttleReset hint, use that if longer. const backoffDelay = Math.min(baseDelay * Math.pow(2, retryCount), 30000); // Cap at 30 seconds // Use throttle reset time if provided and longer than backoff. The hint is // server-controlled, so the wait goes through connection.throttleWait(), which caps // it and keeps the timer tracked and abortable. The connection already waited // part of the back-off before rejecting (throttleWaited), so only the rest is left. const delay = Math.max(err.throttleReset || 0, backoffDelay) - (err.throttleWaited || 0); connection.log.warn({ msg: 'Retrying throttled request with exponential backoff', cid: connection.id, code: err.code, response: err.responseText, throttleReset: err.throttleReset, retryCount, delayMs: delay }); // An aborted wait means the client was closed, so give up rather than reissuing // the FETCH on a connection that is already gone. let aborted = await connection.throttleWait(delay); if (aborted) { throw connection.createNoConnectionError(connection.byeReason, { rejectedFrom: 'throttleAbort', command: 'FETCH' }); } continue; } connection.log.warn({ err, cid: connection.id }); throw err; } } } module.exports = exports.default; Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });