UNPKG

imapflow

Version:

IMAP Client for Node

436 lines (385 loc) 16.6 kB
/* eslint no-control-regex:0 */ 'use strict'; const { formatDate, formatFlag, canUseFlag, isDate } = require('./tools.js'); /** * Sets a boolean flag in the IMAP search attributes. * Automatically handles UN- prefixing for falsy values. * * @param {Array} attributes - Array to append the attribute to * @param {string} term - The flag name (e.g., 'SEEN', 'DELETED') * @param {boolean} value - Whether to set or unset the flag * @example * setBoolOpt(attributes, 'SEEN', false) // Adds 'UNSEEN' * setBoolOpt(attributes, 'UNSEEN', false) // Adds 'SEEN' (removes UN prefix) */ let setBoolOpt = (attributes, term, value) => { if (!value) { // For falsy values, toggle the UN- prefix if (/^un/i.test(term)) { // Remove existing UN prefix term = term.slice(2); } else { // Add UN prefix term = 'UN' + term; } } attributes.push({ type: 'ATOM', value: term.toUpperCase() }); }; /** * Adds a search option with its value(s) to the attributes array. * Handles NOT operations and array values. * * @param {Array} attributes - Array to append the attribute to * @param {string} term - The search term (e.g., 'FROM', 'SUBJECT') * @param {*} value - The value for the search term (string, array, or falsy for NOT) * @param {string} [type='ATOM'] - The attribute type */ let setOpt = (attributes, term, value, type) => { type = type || 'ATOM'; // Handle NOT operations for false or null values if (value === false || value === null) { attributes.push({ type, value: 'NOT' }); } attributes.push({ type, value: term.toUpperCase() }); // Handle array values (e.g., multiple UIDs) if (Array.isArray(value)) { value.forEach(entry => attributes.push({ type, value: (entry || '').toString() })); } else { attributes.push({ type, value: value.toString() }); } }; /** * Processes date fields for IMAP search. * Converts JavaScript dates to IMAP date format. * * @param {Array} attributes - Array to append the attribute to * @param {string} term - The date search term (e.g., 'BEFORE', 'SINCE') * @param {*} value - Date value to format */ let processDateField = (attributes, term, value) => { if (['BEFORE', 'SENTBEFORE'].includes(term.toUpperCase()) && isDate(value) && value.toISOString().substring(11) !== '00:00:00.000Z') { // Set to next day to include current day as well, othwerise BEFORE+AFTER // searches for the same day but different time values do not match anything value = new Date(value.getTime() + 24 * 3600 * 1000); } let date = formatDate(value); if (!date) { return; } setOpt(attributes, term, date); }; // Pre-compiled regex for better performance const UNICODE_PATTERN = /[^\x00-\x7F]/; /** * Checks if a string contains Unicode characters. * Used to determine if CHARSET UTF-8 needs to be specified. * * @param {*} str - String to check * @returns {boolean} True if string contains non-ASCII characters */ let isUnicodeString = str => { if (!str || typeof str !== 'string') { return false; } // Regex test is ~3-5x faster than Buffer.byteLength // Matches any character outside ASCII range (0x00-0x7F) return UNICODE_PATTERN.test(str); }; /** * Compiles a JavaScript object query into IMAP search command attributes. * Supports standard IMAP search criteria and extensions like OBJECTID and Gmail extensions. * * @param {Object} connection - IMAP connection object * @param {Object} connection.capabilities - Set of server capabilities * @param {Object} connection.enabled - Set of enabled extensions * @param {Object} connection.mailbox - Current mailbox information * @param {Set} connection.mailbox.flags - Available flags in the mailbox * @param {Object} query - Search query object * @returns {Array} Array of IMAP search attributes * @throws {Error} When required server extensions are not available * * @example * // Simple search for unseen messages from a sender * searchCompiler(connection, { * unseen: true, * from: 'sender@example.com' * }); * * @example * // Complex OR search with date range * searchCompiler(connection, { * or: [ * { from: 'alice@example.com' }, * { from: 'bob@example.com' } * ], * since: new Date('2024-01-01') * }); */ module.exports.searchCompiler = (connection, query) => { const attributes = []; // Track if we need to specify UTF-8 charset let hasUnicode = false; const mailbox = connection.mailbox; /** * Recursively walks through the query object and builds IMAP attributes. * @param {Object} params - Query parameters to process */ const walk = params => { Object.keys(params || {}).forEach(term => { switch (term.toUpperCase()) { // Custom sequence range support (non-standard) case 'SEQ': { let value = params[term]; if (typeof value === 'number') { value = value.toString(); } // Only accept valid sequence strings (no whitespace) if (typeof value === 'string' && /^\S+$/.test(value)) { attributes.push({ type: 'SEQUENCE', value }); } } break; // Boolean flags that support UN- prefixing case 'ANSWERED': case 'DELETED': case 'DRAFT': case 'FLAGGED': case 'SEEN': case 'UNANSWERED': case 'UNDELETED': case 'UNDRAFT': case 'UNFLAGGED': case 'UNSEEN': // toggles UN-prefix for falsy values setBoolOpt(attributes, term, !!params[term]); break; // Simple boolean flags without UN- support case 'ALL': case 'NEW': case 'OLD': case 'RECENT': if (params[term]) { setBoolOpt(attributes, term, true); } break; // Numeric comparisons case 'LARGER': case 'SMALLER': case 'MODSEQ': if (params[term]) { setOpt(attributes, term, params[term]); } break; // Text search fields - check for Unicode case 'BCC': case 'BODY': case 'CC': case 'FROM': case 'SUBJECT': case 'TEXT': case 'TO': if (isUnicodeString(params[term])) { hasUnicode = true; } if (params[term]) { setOpt(attributes, term, params[term]); } break; // UID sequences case 'UID': if (params[term]) { setOpt(attributes, term, params[term], 'SEQUENCE'); } break; // Email ID support (OBJECTID or Gmail extension) case 'EMAILID': if (connection.capabilities.has('OBJECTID')) { setOpt(attributes, 'EMAILID', params[term]); } else if (connection.capabilities.has('X-GM-EXT-1')) { // Fallback to Gmail message ID setOpt(attributes, 'X-GM-MSGID', params[term]); } break; // Thread ID support (OBJECTID or Gmail extension) case 'THREADID': if (connection.capabilities.has('OBJECTID')) { setOpt(attributes, 'THREADID', params[term]); } else if (connection.capabilities.has('X-GM-EXT-1')) { // Fallback to Gmail thread ID setOpt(attributes, 'X-GM-THRID', params[term]); } break; // Gmail raw search case 'GMRAW': case 'GMAILRAW': // alias for GMRAW if (connection.capabilities.has('X-GM-EXT-1')) { if (isUnicodeString(params[term])) { hasUnicode = true; } setOpt(attributes, 'X-GM-RAW', params[term]); } else { let error = new Error('Server does not support X-GM-EXT-1 extension required for X-GM-RAW'); error.code = 'MissingServerExtension'; throw error; } break; // Date searches with WITHIN extension support case 'BEFORE': case 'SINCE': { // Use WITHIN extension for better timezone handling if available if (connection.capabilities.has('WITHIN') && isDate(params[term])) { // Convert to seconds ago from now const now = Date.now(); const withinSeconds = Math.round(Math.max(0, now - params[term].getTime()) / 1000); let withinKeyword; switch (term.toUpperCase()) { case 'BEFORE': withinKeyword = 'OLDER'; break; case 'SINCE': withinKeyword = 'YOUNGER'; break; } setOpt(attributes, withinKeyword, withinSeconds.toString()); break; } // Fallback to standard date search processDateField(attributes, term, params[term]); } break; // Standard date searches case 'ON': case 'SENTBEFORE': case 'SENTON': case 'SENTSINCE': processDateField(attributes, term, params[term]); break; // Keyword/flag searches case 'KEYWORD': case 'UNKEYWORD': { let flag = formatFlag(params[term]); // Only add if flag is supported or already exists in mailbox if (canUseFlag(mailbox, flag) || mailbox.flags.has(flag)) { setOpt(attributes, term, flag); } } break; // Header field searches case 'HEADER': if (params[term] && typeof params[term] === 'object') { Object.keys(params[term]).forEach(header => { let value = params[term][header]; // Allow boolean true to search for header existence if (value === true) { value = ''; } // Skip non-string values (after true->'' conversion) if (typeof value !== 'string') { return; } if (isUnicodeString(value)) { hasUnicode = true; } setOpt(attributes, term, [header.toUpperCase().trim(), value]); }); } break; // NOT operator case 'NOT': { if (!params[term]) { break; } if (typeof params[term] === 'object') { attributes.push({ type: 'ATOM', value: 'NOT' }); // Recursively process NOT conditions walk(params[term]); } } break; // OR operator - complex logic for building OR trees case 'OR': { if (!params[term] || !Array.isArray(params[term]) || !params[term].length) { break; } // Single element - just process it directly if (params[term].length === 1) { if (typeof params[term][0] === 'object' && params[term][0]) { walk(params[term][0]); } break; } /** * Generates a binary tree structure for OR operations. * IMAP OR takes exactly 2 operands, so we need to nest them. * * @param {Array} list - List of conditions to OR together * @returns {Array} Binary tree structure */ let genOrTree = list => { let group = false; let groups = []; // Group items in pairs list.forEach((entry, i) => { if (i % 2 === 0) { group = [entry]; } else { group.push(entry); groups.push(group); group = false; } }); // Handle odd number of items if (group && group.length) { while (group.length === 1 && Array.isArray(group[0])) { group = group[0]; } groups.push(group); } // Recursively group until we have a binary tree while (groups.length > 2) { groups = genOrTree(groups); } // Flatten single-element arrays while (groups.length === 1 && Array.isArray(groups[0])) { groups = groups[0]; } return groups; }; /** * Walks the OR tree and generates IMAP commands. * @param {Array|Object} entry - Tree node to process */ let walkOrTree = entry => { if (Array.isArray(entry)) { // Only add OR for multiple items if (entry.length > 1) { attributes.push({ type: 'ATOM', value: 'OR' }); } entry.forEach(walkOrTree); return; } if (entry && typeof entry === 'object') { walk(entry); } }; walkOrTree(genOrTree(params[term])); } break; } }); }; // Process the query walk(query); // If we encountered Unicode strings and UTF-8 is not already accepted, // prepend CHARSET UTF-8 to the search command if (hasUnicode && !connection.enabled.has('UTF8=ACCEPT')) { attributes.unshift({ type: 'ATOM', value: 'UTF-8' }); attributes.unshift({ type: 'ATOM', value: 'CHARSET' }); } return attributes; };