Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 131 additions & 1 deletion lib/commands/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,82 @@
const { enhanceCommandError } = require('../tools.js');
const { searchCompiler } = require('../search-compiler.js');

/**
* Parses the key-value attributes from an ESEARCH untagged response.
*
* Receives the attribute list AFTER stripping the leading (TAG "X") list
* and the UID atom — i.e. only the result keyword/value pairs remain.
*
* ALL and PARTIAL.messages are kept as compact sequence-set strings.
* Use expandRange() from tools.js if you need to expand them.
*
* @param {Array} attrs - Attribute array from the IMAP parser
* @returns {Object} ESearchResult object
*/
function parseEsearchResponse(attrs) {
const result = {};
let i = 0;
while (i < attrs.length) {
const token = attrs[i];
if (!token || token.type !== 'ATOM') { i++; continue; }
const key = token.value.toUpperCase();
if (i + 1 >= attrs.length) { i++; continue; }
switch (key) {
case 'COUNT': {
const n = Number(attrs[++i]?.value);
if (!isNaN(n)) result.count = n;
break;
}
case 'MIN': {
const n = Number(attrs[++i]?.value);
if (!isNaN(n)) result.min = n;
break;
}
case 'MAX': {
const n = Number(attrs[++i]?.value);
if (!isNaN(n)) result.max = n;
break;
}
case 'ALL': {
const allToken = attrs[++i];
if (allToken && typeof allToken.value === 'string') {
result.all = allToken.value;
}
break;
}
case 'PARTIAL': {
const listToken = attrs[++i];
if (!listToken || !Array.isArray(listToken.attributes) || listToken.attributes.length < 2) break;
result.partial = {
range: listToken.attributes[0].value,
messages: listToken.attributes[1].value
};
break;
}
default:
// Skip the value token for unknown keys to keep the stream aligned.
// The loop's unconditional i++ at the bottom advances past the key;
// this extra i++ advances past the value token.
i++;
break;
}
i++;
}
return result;
}

/**
* Searches for messages matching the specified criteria.
*
* @param {Object} connection - IMAP connection instance
* @param {Object|boolean} query - Search query object, or true/empty object to match all messages
* @param {Object} [options] - Search options
* @param {boolean} [options.uid] - If true, use UID SEARCH instead of SEARCH
* @returns {Promise<number[]|boolean>} Sorted array of matching sequence numbers or UIDs, or false on failure
* @param {Array} [options.returnOptions] - ESEARCH RETURN options. When present AND the
* server advertises ESEARCH capability, triggers ESEARCH and returns an ESearchResult.
* Items are strings ('MIN','MAX','COUNT','ALL') or objects ({ partial: '1:100' }).
* When server lacks ESEARCH, falls back to plain SEARCH and returns number[].
* @returns {Promise<number[]|Object|boolean>}
*/
module.exports = async (connection, query, options) => {
if (connection.state !== connection.states.SELECTED) {
Expand All @@ -36,6 +104,65 @@ module.exports = async (connection, query, options) => {
return false;
}

const useEsearch = options.returnOptions && options.returnOptions.length > 0 && connection.capabilities.has('ESEARCH');

if (useEsearch) {
// Build RETURN (...) item list
const returnItems = [];
for (const opt of options.returnOptions) {
if (typeof opt === 'string') {
returnItems.push({ type: 'ATOM', value: opt.toUpperCase() });
} else if (opt && typeof opt.partial === 'string') {
// RFC 9394: PARTIAL is an atom followed by the range atom, both inside RETURN (...)
returnItems.push({ type: 'ATOM', value: 'PARTIAL' });
returnItems.push({ type: 'ATOM', value: opt.partial });
}
}

// If all returnOptions entries were invalid (e.g. objects lacking a string
// `partial` field), returnItems would be empty. Emitting "RETURN ()" is
// technically valid per RFC 4731 but returns nothing useful. Fall through
// to the legacy SEARCH path instead so the caller gets a usable result.
if (returnItems.length > 0) {
const returnClause = [
{ type: 'ATOM', value: 'RETURN' },
returnItems
];

let esearchResult = {};
let response;
try {
response = await connection.exec(
options.uid ? 'UID SEARCH' : 'SEARCH',
[...returnClause, ...attributes],
{
untagged: {
ESEARCH: async untagged => {
if (!untagged || !untagged.attributes) return;
// Strip leading (TAG "X") list and optional UID atom.
// The IMAP parser represents parenthesized groups as
// plain Arrays, not objects with type: 'LIST'.
let attrs = untagged.attributes;
let start = 0;
if (attrs[start] && (Array.isArray(attrs[start]) || attrs[start].type === 'LIST')) start++;
if (attrs[start] && typeof attrs[start].value === 'string' && attrs[start].value.toUpperCase() === 'UID') start++;
esearchResult = parseEsearchResponse(attrs.slice(start));
}
}
}
);
response.next();
return esearchResult;
} catch (err) {
await enhanceCommandError(err);
connection.log.warn({ err, cid: connection.id });
return false;
}
}
// returnItems was empty — fall through to legacy SEARCH path below
}

// ── Legacy SEARCH path (no returnOptions, or server lacks ESEARCH) ────
// Use a Set to deduplicate sequence numbers/UIDs -- servers may return
// duplicates across multiple untagged SEARCH responses.
let results = new Set();
Expand Down Expand Up @@ -63,3 +190,6 @@ module.exports = async (connection, query, options) => {
return false;
}
};

// Exported for unit testing — not intended as public library API
module.exports.parseEsearchResponse = parseEsearchResponse;
26 changes: 25 additions & 1 deletion lib/imap-flow.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,25 @@ export interface ResponseEvent {
code?: string;
}

/** Result object returned by ESEARCH (RFC 4731) when returnOptions is specified */
export interface ESearchResult {
/** Total number of matching messages */
count?: number;
/** Lowest matching UID */
min?: number;
/** Highest matching UID */
max?: number;
/** All matching UIDs as compact sequence-set string (e.g. "1,5:10,20") */
all?: string;
/** Paged subset (RFC 9394 PARTIAL) */
partial?: {
/** The requested range, e.g. "1:100" */
range: string;
/** Matching UIDs in that range as compact sequence-set */
messages: string;
};
}

export class AuthenticationFailure extends Error {
authenticationFailed: true;
serverResponseCode?: string;
Expand Down Expand Up @@ -725,8 +744,13 @@ export class ImapFlow extends EventEmitter {
/** Moves messages from current mailbox to destination mailbox */
messageMove(range: SequenceString | number[] | SearchObject, destination: string, options?: { uid?: boolean }): Promise<CopyResponseObject | false>;

/** Search messages from the currently opened mailbox */
/** Search messages from the currently opened mailbox — returns number[] (backward-compatible) */
search(query: SearchObject, options?: { uid?: boolean }): Promise<number[] | false>;
/** Search messages with ESEARCH RETURN options — returns ESearchResult */
search(query: SearchObject, options: {
uid?: boolean;
returnOptions: Array<'MIN' | 'MAX' | 'COUNT' | 'ALL' | { partial: string }>;
}): Promise<ESearchResult | false>;

/** Fetch messages from the currently opened mailbox */
fetch(range: SequenceString | number[] | SearchObject, query: FetchQueryObject, options?: FetchOptions): AsyncIterableIterator<FetchMessageObject>;
Expand Down
37 changes: 36 additions & 1 deletion lib/imap-flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -2599,7 +2599,42 @@ class ImapFlow extends EventEmitter {
return;
}

return (await this.run('SEARCH', query, options)) || false;
const result = (await this.run('SEARCH', query, options)) || false;

// When returnOptions was requested but server lacked ESEARCH capability,
// search.js returns a plain number[]. Derive ESearchResult client-side.
if (options && options.returnOptions && Array.isArray(result)) {
const arr = result;
// Normalize to uppercase so callers can use mixed-case strings like 'count'
const normalizedOptions = options.returnOptions.map(o =>
typeof o === 'string' ? o.toUpperCase() : o
);
const esearch = {};
if (normalizedOptions.includes('COUNT')) {
esearch.count = arr.length;
}
if (normalizedOptions.includes('MIN') && arr.length) {
esearch.min = arr[0]; // already sorted ascending by search.js
}
if (normalizedOptions.includes('MAX') && arr.length) {
esearch.max = arr[arr.length - 1];
}
if (normalizedOptions.includes('ALL') && arr.length) {
esearch.all = packMessageRange(arr);
}
// PARTIAL cannot be derived client-side — omit it.
// When returnOptions contains only { partial: ... } items and the server
// lacks ESEARCH, PARTIAL cannot be derived client-side. Return the raw
// number[] so the caller has actionable data. Note: this is an edge case
// — callers targeting no-ESEARCH servers should avoid requesting PARTIAL
// without COUNT or ALL.
if (Object.keys(esearch).length === 0) {
return result;
}
return esearch;
}

return result;
}

/**
Expand Down
Loading