imapflow 2.5.5 → 2.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.5.7](https://github.com/postalsys/imapflow/compare/v2.5.6...v2.5.7) (2026-10-19)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * bump libmime to 5.5.2 ([8f0f6f7](https://github.com/postalsys/imapflow/commit/8f0f6f7818c0afd601f5e86e8d8b510733162a42))
9
+
10
+ ## [2.5.6](https://github.com/postalsys/imapflow/compare/v2.5.5...v2.5.6) (2026-10-19)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * read SEARCH answers into a number array and reject search() when the command did not complete ([78bfccf](https://github.com/postalsys/imapflow/commit/78bfccfeabd1c4c636f34513154b5de85dbec2fd)), closes [#443](https://github.com/postalsys/imapflow/issues/443) [#444](https://github.com/postalsys/imapflow/issues/444)
16
+
3
17
  ## [2.5.5](https://github.com/postalsys/imapflow/compare/v2.5.4...v2.5.5) (2026-10-18)
4
18
 
5
19
 
@@ -21,6 +21,23 @@ const stripEsearchPrefix = (attrs) => {
21
21
  start++;
22
22
  return attrs.slice(start);
23
23
  };
24
+ /**
25
+ * The failure path of both SEARCH forms. A NO or BAD answer is the server refusing the search,
26
+ * which resolves false. Any other failure means the command never completed (the connection
27
+ * closed or failed, the answer could not be read, the server throttled it), and is rethrown:
28
+ * resolving false would read as "nothing matched" to a caller that treats a non-array result as
29
+ * an empty one.
30
+ *
31
+ * @param connection - IMAP connection instance
32
+ * @param err - The command error
33
+ */
34
+ async function searchFailed(connection, err) {
35
+ await (0, tools_js_1.reportCommandError)(connection, err);
36
+ if ((0, tools_js_1.isServerRefusal)(err)) {
37
+ return false;
38
+ }
39
+ throw err;
40
+ }
24
41
  /**
25
42
  * Searches for messages matching the specified criteria.
26
43
  *
@@ -93,45 +110,61 @@ async function search(connection, query, options) {
93
110
  return esearchResult;
94
111
  }
95
112
  catch (err) {
96
- await (0, tools_js_1.reportCommandError)(connection, err);
97
- return false;
113
+ return await searchFailed(connection, err);
98
114
  }
99
115
  }
100
116
  // returnItems was empty - fall through to legacy SEARCH path below
101
117
  }
102
118
  // Legacy SEARCH path (no returnOptions, or server lacks ESEARCH)
103
- // Use a Set to deduplicate sequence numbers/UIDs - servers may return
104
- // duplicates across multiple untagged SEARCH responses.
105
- let results = new Set();
119
+ // A plain array, deduplicated once all responses are in: on a large mailbox the result
120
+ // holds millions of numbers, and a Set costs several times the memory of an array
121
+ let results = [];
122
+ // How many numbers ESEARCH ALL expansions have added, see the budget below
123
+ let esearchAdded = 0;
106
124
  let response;
107
125
  try {
108
126
  response = await connection.exec(options.uid ? 'UID SEARCH' : 'SEARCH', attributes, {
109
127
  untagged: {
110
128
  SEARCH: async (untagged) => {
111
- if (untagged && untagged.attributes && untagged.attributes.length) {
129
+ if (!untagged) {
130
+ return;
131
+ }
132
+ // A line of plain numbers arrives already read into an array (see parser()),
133
+ // anything else as attribute tokens
134
+ let numbers = untagged.numbers;
135
+ let attributes = untagged.attributes || [];
136
+ let count = numbers ? numbers.length : attributes.length;
137
+ if (count) {
138
+ // The first array of numbers becomes the result itself, checked in place,
139
+ // so a large answer is not held twice
140
+ let target = numbers && !results.length ? numbers : results;
141
+ let size = target === results ? results.length : 0;
112
142
  let truncated = false;
113
143
  let discarded = false;
114
- for (let attribute of untagged.attributes) {
144
+ for (let i = 0; i < count; i++) {
115
145
  // The result set is server-controlled and accumulated across
116
146
  // responses, so stop at the same absolute ceiling expandRange()
117
147
  // uses - a server streaming SEARCH responses could otherwise
118
148
  // grow the set until the process runs out of memory
119
149
  /* c8 ignore next 4 */ // reaching the ceiling needs 2^24 accumulated results, which no unit test can produce in reasonable time
120
- if (results.size >= tools_js_1.EXPANDED_RANGE_LIMIT) {
150
+ if (size >= tools_js_1.EXPANDED_RANGE_LIMIT) {
121
151
  truncated = true;
122
152
  break;
123
153
  }
154
+ let attribute = attributes[i];
155
+ let value = numbers ? numbers[i] : attribute && typeof attribute.value === 'string' ? Number(attribute.value) : NaN;
124
156
  // Same nz-number check the ESEARCH branch below applies. isNaN()
125
157
  // is not enough: it passes '1e400' (Infinity), '-3' and '2.5', and
126
158
  // a single one of those makes the sequence set compiled from this
127
159
  // result set invalid, failing the caller's whole follow-up command
128
- let value = attribute && typeof attribute.value === 'string' ? Number(attribute.value) : NaN;
129
160
  if (!(0, tools_js_1.isValidSequenceValue)(value)) {
130
161
  discarded = true;
131
162
  continue;
132
163
  }
133
- results.add(value);
164
+ target[size++] = value;
134
165
  }
166
+ target.length = size;
167
+ results = target;
135
168
  if (truncated || discarded) {
136
169
  connection.log.warn({
137
170
  msg: 'Invalid entries in the SEARCH result',
@@ -151,7 +184,7 @@ async function search(connection, query, options) {
151
184
  }
152
185
  let parsed = (0, esearch_parser_js_1.parseEsearchResponse)(stripEsearchPrefix(untagged.attributes));
153
186
  if (parsed.all) {
154
- // Walk the compact sequence-set directly into the Set - the ALL
187
+ // Walk the compact sequence-set directly into the results - the ALL
155
188
  // result may cover the entire mailbox, so expanding it into an
156
189
  // intermediate array first would double the peak memory use.
157
190
  // The set comes from an untrusted server: endpoints must be
@@ -164,11 +197,17 @@ async function search(connection, query, options) {
164
197
  // not contain '*' at all (RFC 9051 section 4.1.1), so UID
165
198
  // parts with '*' are dropped
166
199
  let existsCount = () => mailbox.exists || 0;
167
- // The mailbox EXISTS count is itself server-supplied and can be
168
- // absurdly large, so the budget is additionally capped at the same
169
- // absolute ceiling expandRange() uses - a hostile server cannot
170
- // bypass it by inflating EXISTS first
171
- let overBudget = () => results.size >= existsCount() || results.size >= tools_js_1.EXPANDED_RANGE_LIMIT;
200
+ // The budget counts what the expansions added, across responses, so
201
+ // a server repeating the same range cannot multiply it. The mailbox
202
+ // EXISTS count is itself server-supplied and can be absurdly large, so
203
+ // the budget is additionally capped at the same absolute ceiling
204
+ // expandRange() uses - a hostile server cannot bypass it by inflating
205
+ // EXISTS first
206
+ let overBudget = () => esearchAdded >= existsCount() || results.length >= tools_js_1.EXPANDED_RANGE_LIMIT;
207
+ let add = (value) => {
208
+ results.push(value);
209
+ esearchAdded++;
210
+ };
172
211
  let resolveId = (part) => (part === '*' ? (options.uid ? 0 : existsCount()) : Number(part));
173
212
  let truncated = false;
174
213
  let discarded = false;
@@ -185,7 +224,7 @@ async function search(connection, query, options) {
185
224
  truncated = true;
186
225
  break;
187
226
  }
188
- results.add(value);
227
+ add(value);
189
228
  continue;
190
229
  }
191
230
  let first = resolveId(part.substring(0, colon));
@@ -199,7 +238,7 @@ async function search(connection, query, options) {
199
238
  truncated = true;
200
239
  break sequenceSetLoop;
201
240
  }
202
- results.add(id);
241
+ add(id);
203
242
  }
204
243
  }
205
244
  if (truncated || discarded) {
@@ -215,12 +254,20 @@ async function search(connection, query, options) {
215
254
  }
216
255
  });
217
256
  response.next();
218
- // Sort numerically for consistent, predictable output order
219
- return Array.from(results).sort((a, b) => a - b);
257
+ // Sort numerically for consistent, predictable output order, then drop the duplicates
258
+ // in place: servers may repeat numbers across multiple untagged responses
259
+ results.sort((a, b) => a - b);
260
+ let unique = 0;
261
+ for (let i = 0; i < results.length; i++) {
262
+ if (!unique || results[i] !== results[unique - 1]) {
263
+ results[unique++] = results[i];
264
+ }
265
+ }
266
+ results.length = unique;
267
+ return results;
220
268
  }
221
269
  catch (err) {
222
- await (0, tools_js_1.reportCommandError)(connection, err);
223
- return false;
270
+ return await searchFailed(connection, err);
224
271
  }
225
272
  }
226
273
  module.exports = exports.default;
@@ -287,6 +287,11 @@ function compiler(response, options) {
287
287
  walk(child);
288
288
  }
289
289
  }
290
+ if (response.numbers) {
291
+ for (let value of response.numbers) {
292
+ walk(value);
293
+ }
294
+ }
290
295
  if (resp.length) {
291
296
  respParts.push(resp);
292
297
  }
@@ -10,7 +10,8 @@ import type { ImapResponse, ParserOptions } from './types.js';
10
10
  * @param options.literals - Pre-parsed literal values extracted from the input stream.
11
11
  * @returns A promise that resolves to a parsed response object with `tag` (the IMAP tag, e.g. "*",
12
12
  * "+", or a command tag like "A1"), `command` (the IMAP command or response name, e.g. "OK",
13
- * "FETCH"), `attributes` (parsed attributes of the response) and `nullBytesRemoved` (number of
14
- * leading null bytes removed, if any).
13
+ * "FETCH"), `attributes` (parsed attributes of the response), `numbers` (the numbers of an
14
+ * untagged SEARCH response that holds nothing else, instead of `attributes`) and
15
+ * `nullBytesRemoved` (number of leading null bytes removed, if any).
15
16
  */
16
17
  export default function parser(command: Buffer | string, options?: ParserOptions | undefined): Promise<ImapResponse>;
@@ -31,6 +31,56 @@ function asParserError(err, input) {
31
31
  ...(err instanceof Error ? { _err: err } : {})
32
32
  });
33
33
  }
34
+ /**
35
+ * Reads the numbers of an untagged SEARCH response, the part of the line after the command
36
+ * (" 2 5 9"), into an array. A SEARCH answer holds one number per matching message, so on a
37
+ * large mailbox one line carries millions of them, and run through the tokenizer each would
38
+ * become a parse tree node and an attribute object, some 30 times the size of the line in heap.
39
+ *
40
+ * @param str - The rest of the line after the command
41
+ * @returns The numbers, or null when the line holds anything but a space before every number
42
+ * and trailing spaces (a CONDSTORE MODSEQ list, extra spaces, a number past 2^53), which is
43
+ * left to the tokenizer
44
+ */
45
+ function readSearchNumbers(str) {
46
+ // trailing spaces are tolerated, as the tokenizer does, some servers end the line with one
47
+ let end = str.length;
48
+ while (end > 1 && str.charCodeAt(end - 1) === 0x20) {
49
+ end--;
50
+ }
51
+ if (end < 2 || str.charCodeAt(0) !== 0x20) {
52
+ return null;
53
+ }
54
+ const numbers = [];
55
+ // -1 until the first digit of the current number
56
+ let value = -1;
57
+ for (let i = 1; i < end; i++) {
58
+ const c = str.charCodeAt(i);
59
+ if (c === 0x20) {
60
+ if (value < 0) {
61
+ return null;
62
+ }
63
+ numbers.push(value);
64
+ value = -1;
65
+ }
66
+ else if (c >= 0x30 && c <= 0x39) {
67
+ value = (value < 0 ? 0 : value * 10) + (c - 0x30);
68
+ // past 2^53 the digits no longer add up exactly, a line with such a number is left
69
+ // to the tokenizer
70
+ if (value > Number.MAX_SAFE_INTEGER) {
71
+ return null;
72
+ }
73
+ }
74
+ else {
75
+ return null;
76
+ }
77
+ }
78
+ if (value < 0) {
79
+ return null;
80
+ }
81
+ numbers.push(value);
82
+ return numbers;
83
+ }
34
84
  /**
35
85
  * Parses a raw IMAP command or response buffer into a structured object.
36
86
  * Handles edge cases such as null-byte-padded responses from buggy servers and
@@ -42,8 +92,9 @@ function asParserError(err, input) {
42
92
  * @param options.literals - Pre-parsed literal values extracted from the input stream.
43
93
  * @returns A promise that resolves to a parsed response object with `tag` (the IMAP tag, e.g. "*",
44
94
  * "+", or a command tag like "A1"), `command` (the IMAP command or response name, e.g. "OK",
45
- * "FETCH"), `attributes` (parsed attributes of the response) and `nullBytesRemoved` (number of
46
- * leading null bytes removed, if any).
95
+ * "FETCH"), `attributes` (parsed attributes of the response), `numbers` (the numbers of an
96
+ * untagged SEARCH response that holds nothing else, instead of `attributes`) and
97
+ * `nullBytesRemoved` (number of leading null bytes removed, if any).
47
98
  */
48
99
  async function parser(command, options) {
49
100
  options = options || {};
@@ -84,6 +135,13 @@ async function parser(command, options) {
84
135
  await parserInstance.getSpace();
85
136
  response.command += ' ' + (await parserInstance.getElement(imap_formal_syntax_js_1.default.command()));
86
137
  }
138
+ if (response.tag === '*' && (response.command || '').toUpperCase() === 'SEARCH') {
139
+ const numbers = readSearchNumbers(parserInstance.remainder);
140
+ if (numbers) {
141
+ response.numbers = numbers;
142
+ return response;
143
+ }
144
+ }
87
145
  if (parserInstance.remainder.trim().length) {
88
146
  await parserInstance.getSpace();
89
147
  response.attributes = await parserInstance.getAttributes();
@@ -37,6 +37,11 @@ export interface ImapResponse {
37
37
  command?: string | undefined;
38
38
  /** Parsed attributes of the response */
39
39
  attributes?: ImapAttributeList | undefined;
40
+ /**
41
+ * The numbers of an untagged SEARCH response (`* SEARCH 2 5 9`), set instead of `attributes`
42
+ * when the line holds nothing else, see parser()
43
+ */
44
+ numbers?: number[] | undefined;
40
45
  /** Number of leading NUL bytes stripped from the line, if any */
41
46
  nullBytesRemoved?: number | undefined;
42
47
  /**
@@ -54,6 +59,8 @@ export interface ImapCompileInput {
54
59
  tag?: string | undefined;
55
60
  command?: string | undefined;
56
61
  attributes?: ImapCompileNode | ImapCompileNode[] | undefined;
62
+ /** The numbers of a parsed SEARCH response, written after the attributes */
63
+ numbers?: number[] | undefined;
57
64
  }
58
65
  export interface CompilerOptions {
59
66
  /** Return an array of Buffers, one per literal segment, instead of a single Buffer */
@@ -470,7 +470,11 @@ export declare class ImapFlow extends EventEmitter {
470
470
  *
471
471
  * @param query Query to filter the messages
472
472
  * @param options Search options. With `returnOptions` set the result is an ESEARCH result object
473
- * @returns An array of sequence or UID numbers, or an ESearchResult when `returnOptions` was used
473
+ * @returns An array of sequence or UID numbers, or an ESearchResult when `returnOptions` was used.
474
+ * `false` when the server rejected the search (a NO or BAD answer, for example an unsupported
475
+ * charset or search key), `undefined` when no mailbox is open. Rejects when the command could
476
+ * not complete, for example because the connection closed or failed, so a failed search is
477
+ * never mistaken for an empty result
474
478
  *
475
479
  * @example
476
480
  * let mailbox = await client.mailboxOpen('INBOX');
@@ -1,3 +1,3 @@
1
1
  export declare const name = "imapflow";
2
- export declare const version = "2.5.5";
2
+ export declare const version = "2.5.7";
3
3
  export declare const homepage = "https://imapflow.com/";
@@ -3,5 +3,5 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.homepage = exports.version = exports.name = void 0;
5
5
  exports.name = "imapflow";
6
- exports.version = "2.5.5";
6
+ exports.version = "2.5.7";
7
7
  exports.homepage = "https://imapflow.com/";
@@ -132,7 +132,9 @@ export interface ImapFlowOptions {
132
132
  * accepted. `Infinity` disables the limit. An in-progress line is additionally bounded by
133
133
  * whatever is left of `maxResponseSize`, so lowering that also bounds line buffering.
134
134
  * Exceeding it is terminal: the connection fails with error code `LineTooLarge` and
135
- * no further input is parsed.
135
+ * no further input is parsed. The limit bounds the bytes of a line, not the memory its parse
136
+ * takes, which is several times the line size for a line of many small tokens. A SEARCH
137
+ * answer of plain numbers is read into an array of about the size of the line.
136
138
  */
137
139
  maxLineLength?: number | undefined;
138
140
  /**
@@ -1,4 +1,4 @@
1
- import { hasCapability, isValidSequenceValue, EXPANDED_RANGE_LIMIT, reportCommandError, getSelectedMailbox } from '../tools.js';
1
+ import { hasCapability, isValidSequenceValue, isServerRefusal, EXPANDED_RANGE_LIMIT, reportCommandError, getSelectedMailbox } from '../tools.js';
2
2
  import { searchCompiler } from '../search-compiler.js';
3
3
  import { parseEsearchResponse } from './esearch-parser.js';
4
4
  /**
@@ -18,6 +18,23 @@ const stripEsearchPrefix = (attrs) => {
18
18
  start++;
19
19
  return attrs.slice(start);
20
20
  };
21
+ /**
22
+ * The failure path of both SEARCH forms. A NO or BAD answer is the server refusing the search,
23
+ * which resolves false. Any other failure means the command never completed (the connection
24
+ * closed or failed, the answer could not be read, the server throttled it), and is rethrown:
25
+ * resolving false would read as "nothing matched" to a caller that treats a non-array result as
26
+ * an empty one.
27
+ *
28
+ * @param connection - IMAP connection instance
29
+ * @param err - The command error
30
+ */
31
+ async function searchFailed(connection, err) {
32
+ await reportCommandError(connection, err);
33
+ if (isServerRefusal(err)) {
34
+ return false;
35
+ }
36
+ throw err;
37
+ }
21
38
  /**
22
39
  * Searches for messages matching the specified criteria.
23
40
  *
@@ -90,45 +107,61 @@ export default async function search(connection, query, options) {
90
107
  return esearchResult;
91
108
  }
92
109
  catch (err) {
93
- await reportCommandError(connection, err);
94
- return false;
110
+ return await searchFailed(connection, err);
95
111
  }
96
112
  }
97
113
  // returnItems was empty - fall through to legacy SEARCH path below
98
114
  }
99
115
  // Legacy SEARCH path (no returnOptions, or server lacks ESEARCH)
100
- // Use a Set to deduplicate sequence numbers/UIDs - servers may return
101
- // duplicates across multiple untagged SEARCH responses.
102
- let results = new Set();
116
+ // A plain array, deduplicated once all responses are in: on a large mailbox the result
117
+ // holds millions of numbers, and a Set costs several times the memory of an array
118
+ let results = [];
119
+ // How many numbers ESEARCH ALL expansions have added, see the budget below
120
+ let esearchAdded = 0;
103
121
  let response;
104
122
  try {
105
123
  response = await connection.exec(options.uid ? 'UID SEARCH' : 'SEARCH', attributes, {
106
124
  untagged: {
107
125
  SEARCH: async (untagged) => {
108
- if (untagged && untagged.attributes && untagged.attributes.length) {
126
+ if (!untagged) {
127
+ return;
128
+ }
129
+ // A line of plain numbers arrives already read into an array (see parser()),
130
+ // anything else as attribute tokens
131
+ let numbers = untagged.numbers;
132
+ let attributes = untagged.attributes || [];
133
+ let count = numbers ? numbers.length : attributes.length;
134
+ if (count) {
135
+ // The first array of numbers becomes the result itself, checked in place,
136
+ // so a large answer is not held twice
137
+ let target = numbers && !results.length ? numbers : results;
138
+ let size = target === results ? results.length : 0;
109
139
  let truncated = false;
110
140
  let discarded = false;
111
- for (let attribute of untagged.attributes) {
141
+ for (let i = 0; i < count; i++) {
112
142
  // The result set is server-controlled and accumulated across
113
143
  // responses, so stop at the same absolute ceiling expandRange()
114
144
  // uses - a server streaming SEARCH responses could otherwise
115
145
  // grow the set until the process runs out of memory
116
146
  /* c8 ignore next 4 */ // reaching the ceiling needs 2^24 accumulated results, which no unit test can produce in reasonable time
117
- if (results.size >= EXPANDED_RANGE_LIMIT) {
147
+ if (size >= EXPANDED_RANGE_LIMIT) {
118
148
  truncated = true;
119
149
  break;
120
150
  }
151
+ let attribute = attributes[i];
152
+ let value = numbers ? numbers[i] : attribute && typeof attribute.value === 'string' ? Number(attribute.value) : NaN;
121
153
  // Same nz-number check the ESEARCH branch below applies. isNaN()
122
154
  // is not enough: it passes '1e400' (Infinity), '-3' and '2.5', and
123
155
  // a single one of those makes the sequence set compiled from this
124
156
  // result set invalid, failing the caller's whole follow-up command
125
- let value = attribute && typeof attribute.value === 'string' ? Number(attribute.value) : NaN;
126
157
  if (!isValidSequenceValue(value)) {
127
158
  discarded = true;
128
159
  continue;
129
160
  }
130
- results.add(value);
161
+ target[size++] = value;
131
162
  }
163
+ target.length = size;
164
+ results = target;
132
165
  if (truncated || discarded) {
133
166
  connection.log.warn({
134
167
  msg: 'Invalid entries in the SEARCH result',
@@ -148,7 +181,7 @@ export default async function search(connection, query, options) {
148
181
  }
149
182
  let parsed = parseEsearchResponse(stripEsearchPrefix(untagged.attributes));
150
183
  if (parsed.all) {
151
- // Walk the compact sequence-set directly into the Set - the ALL
184
+ // Walk the compact sequence-set directly into the results - the ALL
152
185
  // result may cover the entire mailbox, so expanding it into an
153
186
  // intermediate array first would double the peak memory use.
154
187
  // The set comes from an untrusted server: endpoints must be
@@ -161,11 +194,17 @@ export default async function search(connection, query, options) {
161
194
  // not contain '*' at all (RFC 9051 section 4.1.1), so UID
162
195
  // parts with '*' are dropped
163
196
  let existsCount = () => mailbox.exists || 0;
164
- // The mailbox EXISTS count is itself server-supplied and can be
165
- // absurdly large, so the budget is additionally capped at the same
166
- // absolute ceiling expandRange() uses - a hostile server cannot
167
- // bypass it by inflating EXISTS first
168
- let overBudget = () => results.size >= existsCount() || results.size >= EXPANDED_RANGE_LIMIT;
197
+ // The budget counts what the expansions added, across responses, so
198
+ // a server repeating the same range cannot multiply it. The mailbox
199
+ // EXISTS count is itself server-supplied and can be absurdly large, so
200
+ // the budget is additionally capped at the same absolute ceiling
201
+ // expandRange() uses - a hostile server cannot bypass it by inflating
202
+ // EXISTS first
203
+ let overBudget = () => esearchAdded >= existsCount() || results.length >= EXPANDED_RANGE_LIMIT;
204
+ let add = (value) => {
205
+ results.push(value);
206
+ esearchAdded++;
207
+ };
169
208
  let resolveId = (part) => (part === '*' ? (options.uid ? 0 : existsCount()) : Number(part));
170
209
  let truncated = false;
171
210
  let discarded = false;
@@ -182,7 +221,7 @@ export default async function search(connection, query, options) {
182
221
  truncated = true;
183
222
  break;
184
223
  }
185
- results.add(value);
224
+ add(value);
186
225
  continue;
187
226
  }
188
227
  let first = resolveId(part.substring(0, colon));
@@ -196,7 +235,7 @@ export default async function search(connection, query, options) {
196
235
  truncated = true;
197
236
  break sequenceSetLoop;
198
237
  }
199
- results.add(id);
238
+ add(id);
200
239
  }
201
240
  }
202
241
  if (truncated || discarded) {
@@ -212,11 +251,19 @@ export default async function search(connection, query, options) {
212
251
  }
213
252
  });
214
253
  response.next();
215
- // Sort numerically for consistent, predictable output order
216
- return Array.from(results).sort((a, b) => a - b);
254
+ // Sort numerically for consistent, predictable output order, then drop the duplicates
255
+ // in place: servers may repeat numbers across multiple untagged responses
256
+ results.sort((a, b) => a - b);
257
+ let unique = 0;
258
+ for (let i = 0; i < results.length; i++) {
259
+ if (!unique || results[i] !== results[unique - 1]) {
260
+ results[unique++] = results[i];
261
+ }
262
+ }
263
+ results.length = unique;
264
+ return results;
217
265
  }
218
266
  catch (err) {
219
- await reportCommandError(connection, err);
220
- return false;
267
+ return await searchFailed(connection, err);
221
268
  }
222
269
  }
@@ -282,6 +282,11 @@ function compiler(response, options) {
282
282
  walk(child);
283
283
  }
284
284
  }
285
+ if (response.numbers) {
286
+ for (let value of response.numbers) {
287
+ walk(value);
288
+ }
289
+ }
285
290
  if (resp.length) {
286
291
  respParts.push(resp);
287
292
  }
@@ -10,7 +10,8 @@ import type { ImapResponse, ParserOptions } from './types.js';
10
10
  * @param options.literals - Pre-parsed literal values extracted from the input stream.
11
11
  * @returns A promise that resolves to a parsed response object with `tag` (the IMAP tag, e.g. "*",
12
12
  * "+", or a command tag like "A1"), `command` (the IMAP command or response name, e.g. "OK",
13
- * "FETCH"), `attributes` (parsed attributes of the response) and `nullBytesRemoved` (number of
14
- * leading null bytes removed, if any).
13
+ * "FETCH"), `attributes` (parsed attributes of the response), `numbers` (the numbers of an
14
+ * untagged SEARCH response that holds nothing else, instead of `attributes`) and
15
+ * `nullBytesRemoved` (number of leading null bytes removed, if any).
15
16
  */
16
17
  export default function parser(command: Buffer | string, options?: ParserOptions | undefined): Promise<ImapResponse>;
@@ -25,6 +25,56 @@ function asParserError(err, input) {
25
25
  ...(err instanceof Error ? { _err: err } : {})
26
26
  });
27
27
  }
28
+ /**
29
+ * Reads the numbers of an untagged SEARCH response, the part of the line after the command
30
+ * (" 2 5 9"), into an array. A SEARCH answer holds one number per matching message, so on a
31
+ * large mailbox one line carries millions of them, and run through the tokenizer each would
32
+ * become a parse tree node and an attribute object, some 30 times the size of the line in heap.
33
+ *
34
+ * @param str - The rest of the line after the command
35
+ * @returns The numbers, or null when the line holds anything but a space before every number
36
+ * and trailing spaces (a CONDSTORE MODSEQ list, extra spaces, a number past 2^53), which is
37
+ * left to the tokenizer
38
+ */
39
+ function readSearchNumbers(str) {
40
+ // trailing spaces are tolerated, as the tokenizer does, some servers end the line with one
41
+ let end = str.length;
42
+ while (end > 1 && str.charCodeAt(end - 1) === 0x20) {
43
+ end--;
44
+ }
45
+ if (end < 2 || str.charCodeAt(0) !== 0x20) {
46
+ return null;
47
+ }
48
+ const numbers = [];
49
+ // -1 until the first digit of the current number
50
+ let value = -1;
51
+ for (let i = 1; i < end; i++) {
52
+ const c = str.charCodeAt(i);
53
+ if (c === 0x20) {
54
+ if (value < 0) {
55
+ return null;
56
+ }
57
+ numbers.push(value);
58
+ value = -1;
59
+ }
60
+ else if (c >= 0x30 && c <= 0x39) {
61
+ value = (value < 0 ? 0 : value * 10) + (c - 0x30);
62
+ // past 2^53 the digits no longer add up exactly, a line with such a number is left
63
+ // to the tokenizer
64
+ if (value > Number.MAX_SAFE_INTEGER) {
65
+ return null;
66
+ }
67
+ }
68
+ else {
69
+ return null;
70
+ }
71
+ }
72
+ if (value < 0) {
73
+ return null;
74
+ }
75
+ numbers.push(value);
76
+ return numbers;
77
+ }
28
78
  /**
29
79
  * Parses a raw IMAP command or response buffer into a structured object.
30
80
  * Handles edge cases such as null-byte-padded responses from buggy servers and
@@ -36,8 +86,9 @@ function asParserError(err, input) {
36
86
  * @param options.literals - Pre-parsed literal values extracted from the input stream.
37
87
  * @returns A promise that resolves to a parsed response object with `tag` (the IMAP tag, e.g. "*",
38
88
  * "+", or a command tag like "A1"), `command` (the IMAP command or response name, e.g. "OK",
39
- * "FETCH"), `attributes` (parsed attributes of the response) and `nullBytesRemoved` (number of
40
- * leading null bytes removed, if any).
89
+ * "FETCH"), `attributes` (parsed attributes of the response), `numbers` (the numbers of an
90
+ * untagged SEARCH response that holds nothing else, instead of `attributes`) and
91
+ * `nullBytesRemoved` (number of leading null bytes removed, if any).
41
92
  */
42
93
  export default async function parser(command, options) {
43
94
  options = options || {};
@@ -78,6 +129,13 @@ export default async function parser(command, options) {
78
129
  await parserInstance.getSpace();
79
130
  response.command += ' ' + (await parserInstance.getElement(imapFormalSyntax.command()));
80
131
  }
132
+ if (response.tag === '*' && (response.command || '').toUpperCase() === 'SEARCH') {
133
+ const numbers = readSearchNumbers(parserInstance.remainder);
134
+ if (numbers) {
135
+ response.numbers = numbers;
136
+ return response;
137
+ }
138
+ }
81
139
  if (parserInstance.remainder.trim().length) {
82
140
  await parserInstance.getSpace();
83
141
  response.attributes = await parserInstance.getAttributes();
@@ -37,6 +37,11 @@ export interface ImapResponse {
37
37
  command?: string | undefined;
38
38
  /** Parsed attributes of the response */
39
39
  attributes?: ImapAttributeList | undefined;
40
+ /**
41
+ * The numbers of an untagged SEARCH response (`* SEARCH 2 5 9`), set instead of `attributes`
42
+ * when the line holds nothing else, see parser()
43
+ */
44
+ numbers?: number[] | undefined;
40
45
  /** Number of leading NUL bytes stripped from the line, if any */
41
46
  nullBytesRemoved?: number | undefined;
42
47
  /**
@@ -54,6 +59,8 @@ export interface ImapCompileInput {
54
59
  tag?: string | undefined;
55
60
  command?: string | undefined;
56
61
  attributes?: ImapCompileNode | ImapCompileNode[] | undefined;
62
+ /** The numbers of a parsed SEARCH response, written after the attributes */
63
+ numbers?: number[] | undefined;
57
64
  }
58
65
  export interface CompilerOptions {
59
66
  /** Return an array of Buffers, one per literal segment, instead of a single Buffer */
@@ -470,7 +470,11 @@ export declare class ImapFlow extends EventEmitter {
470
470
  *
471
471
  * @param query Query to filter the messages
472
472
  * @param options Search options. With `returnOptions` set the result is an ESEARCH result object
473
- * @returns An array of sequence or UID numbers, or an ESearchResult when `returnOptions` was used
473
+ * @returns An array of sequence or UID numbers, or an ESearchResult when `returnOptions` was used.
474
+ * `false` when the server rejected the search (a NO or BAD answer, for example an unsupported
475
+ * charset or search key), `undefined` when no mailbox is open. Rejects when the command could
476
+ * not complete, for example because the connection closed or failed, so a failed search is
477
+ * never mistaken for an empty result
474
478
  *
475
479
  * @example
476
480
  * let mailbox = await client.mailboxOpen('INBOX');
@@ -1,3 +1,3 @@
1
1
  export declare const name = "imapflow";
2
- export declare const version = "2.5.5";
2
+ export declare const version = "2.5.7";
3
3
  export declare const homepage = "https://imapflow.com/";
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/build.js from package.json. Do not edit by hand.
2
2
  export const name = "imapflow";
3
- export const version = "2.5.5";
3
+ export const version = "2.5.7";
4
4
  export const homepage = "https://imapflow.com/";
@@ -132,7 +132,9 @@ export interface ImapFlowOptions {
132
132
  * accepted. `Infinity` disables the limit. An in-progress line is additionally bounded by
133
133
  * whatever is left of `maxResponseSize`, so lowering that also bounds line buffering.
134
134
  * Exceeding it is terminal: the connection fails with error code `LineTooLarge` and
135
- * no further input is parsed.
135
+ * no further input is parsed. The limit bounds the bytes of a line, not the memory its parse
136
+ * takes, which is several times the line size for a line of many small tokens. A SEARCH
137
+ * answer of plain numbers is read into an array of about the size of the line.
136
138
  */
137
139
  maxLineLength?: number | undefined;
138
140
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "2.5.5",
3
+ "version": "2.5.7",
4
4
  "description": "IMAP Client for Node",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/imap-flow.js",
@@ -40,6 +40,8 @@
40
40
  "test": "node --import tsx --test --test-force-exit --test-concurrency=1 $(find test -name '*-test.ts' -not -path '*/integration/*' -not -path '*/cloudflare/*' -not -path '*/celld/*' -not -path '*/browser/*')",
41
41
  "pretest:coverage": "npm run build",
42
42
  "test:coverage": "c8 node --import tsx --test --test-force-exit --test-concurrency=1 $(find test -name '*-test.ts' -not -path '*/integration/*' -not -path '*/cloudflare/*' -not -path '*/celld/*' -not -path '*/browser/*')",
43
+ "pretest:cjs": "npm run build",
44
+ "test:cjs": "NODE_OPTIONS=--no-experimental-require-module node test/package/cjs-smoke.cjs",
43
45
  "pretest:bun": "npm run build",
44
46
  "test:bun": "bun test --timeout 60000 $(find ./test -name '*-test.ts' -not -path '*/integration/*' -not -path '*/cloudflare/*' -not -path '*/celld/*' -not -path '*/browser/*')",
45
47
  "pretest:workers": "npm run build",
@@ -92,14 +94,14 @@
92
94
  "types-node-legacy": "npm:@types/node@20.0.0",
93
95
  "typescript": "6.0.3",
94
96
  "typescript-eslint": "8.71.2",
95
- "wrangler": "4.151.1"
97
+ "wrangler": "4.151.2"
96
98
  },
97
99
  "dependencies": {
98
- "@zone-eu/mailsplit": "5.4.26",
100
+ "@zone-eu/mailsplit": "5.4.27",
99
101
  "encoding-japanese": "2.4.0",
100
102
  "iconv-lite": "0.7.3",
101
103
  "libbase64": "1.4.1",
102
- "libmime": "5.5.1",
104
+ "libmime": "5.5.2",
103
105
  "libqp": "3.0.1",
104
106
  "pino": "10.3.1",
105
107
  "socks": "2.8.10"