imapflow 2.5.5 → 2.5.6
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 +7 -0
- package/dist/cjs/commands/search.js +69 -22
- package/dist/cjs/handler/imap-compiler.js +5 -0
- package/dist/cjs/handler/imap-parser.d.ts +3 -2
- package/dist/cjs/handler/imap-parser.js +60 -2
- package/dist/cjs/handler/types.d.ts +7 -0
- package/dist/cjs/imap-flow.d.ts +5 -1
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/cjs/types.d.ts +3 -1
- package/dist/esm/commands/search.js +70 -23
- package/dist/esm/handler/imap-compiler.js +5 -0
- package/dist/esm/handler/imap-parser.d.ts +3 -2
- package/dist/esm/handler/imap-parser.js +60 -2
- package/dist/esm/handler/types.d.ts +7 -0
- package/dist/esm/imap-flow.d.ts +5 -1
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/dist/esm/types.d.ts +3 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.5.6](https://github.com/postalsys/imapflow/compare/v2.5.5...v2.5.6) (2026-10-19)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* 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)
|
|
9
|
+
|
|
3
10
|
## [2.5.5](https://github.com/postalsys/imapflow/compare/v2.5.4...v2.5.5) (2026-10-18)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -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 (
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
let results =
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
223
|
-
return false;
|
|
270
|
+
return await searchFailed(connection, err);
|
|
224
271
|
}
|
|
225
272
|
}
|
|
226
273
|
module.exports = exports.default;
|
|
@@ -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)
|
|
14
|
-
*
|
|
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)
|
|
46
|
-
*
|
|
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 */
|
package/dist/cjs/imap-flow.d.ts
CHANGED
|
@@ -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');
|
package/dist/cjs/package-info.js
CHANGED
package/dist/cjs/types.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
101
|
-
//
|
|
102
|
-
let results =
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
220
|
-
return false;
|
|
267
|
+
return await searchFailed(connection, err);
|
|
221
268
|
}
|
|
222
269
|
}
|
|
@@ -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)
|
|
14
|
-
*
|
|
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)
|
|
40
|
-
*
|
|
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 */
|
package/dist/esm/imap-flow.d.ts
CHANGED
|
@@ -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');
|
package/dist/esm/package-info.js
CHANGED
package/dist/esm/types.d.ts
CHANGED
|
@@ -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
|
/**
|