imapflow 2.2.5 → 2.2.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 +8 -0
- package/dist/cjs/commands/copyuid-parser.js +16 -2
- package/dist/cjs/handler/imap-compiler.js +26 -19
- package/dist/cjs/handler/token-parser.js +127 -51
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/cjs/search-compiler.js +7 -3
- package/dist/esm/commands/copyuid-parser.js +16 -2
- package/dist/esm/handler/imap-compiler.js +26 -19
- package/dist/esm/handler/token-parser.js +127 -51
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/dist/esm/search-compiler.js +7 -3
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.2.6](https://github.com/postalsys/imapflow/compare/v2.2.5...v2.2.6) (2026-10-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* harden response parsing and command compilation found by fuzzing ([85b842a](https://github.com/postalsys/imapflow/commit/85b842a2eafd92afad90649968a11cdeee363758))
|
|
9
|
+
* read digit-led atoms like "2024:Q1" instead of dropping the response line ([47bf6ec](https://github.com/postalsys/imapflow/commit/47bf6eca2178bad831ccc878f75f165ed1b8d4e9))
|
|
10
|
+
|
|
3
11
|
## [2.2.5](https://github.com/postalsys/imapflow/compare/v2.2.4...v2.2.5) (2026-10-04)
|
|
4
12
|
|
|
5
13
|
|
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.parseCopyUid = parseCopyUid;
|
|
4
4
|
const tools_js_1 = require("../tools.js");
|
|
5
|
+
/**
|
|
6
|
+
* Whether a value is a complete uid-set (RFC 4315): comma separated UIDs and UID ranges. The UID
|
|
7
|
+
* map pairs the source and destination sets by position, so a set with a malformed element must
|
|
8
|
+
* be refused as a whole: skipping the element, as expandRange() does, would shift every UID after
|
|
9
|
+
* it onto the wrong counterpart. Checked per element, as a whole-set regex with an unbounded
|
|
10
|
+
* repeat can overflow the regex engine on very long sets.
|
|
11
|
+
*/
|
|
12
|
+
const UID_SET_ELEMENT = /^\d+(:\d+)?$/;
|
|
13
|
+
const isUidSet = (value) => typeof value === 'string' && value.split(',').every(part => UID_SET_ELEMENT.test(part));
|
|
14
|
+
// The UIDs of a uid-set attribute, or false when the attribute is not a complete uid-set
|
|
15
|
+
const uidSetValues = (attribute) => {
|
|
16
|
+
const value = attribute && attribute.value;
|
|
17
|
+
return isUidSet(value) ? (0, tools_js_1.expandRange)(value) : false;
|
|
18
|
+
};
|
|
5
19
|
/**
|
|
6
20
|
* Parses COPYUID response code from an IMAP response (RFC 4315).
|
|
7
21
|
* Used by both COPY and MOVE commands to extract the UID mapping
|
|
@@ -24,8 +38,8 @@ function parseCopyUid(response, map) {
|
|
|
24
38
|
if (uidValidity !== false) {
|
|
25
39
|
map.uidValidity = uidValidity;
|
|
26
40
|
}
|
|
27
|
-
const sourceUids =
|
|
28
|
-
const destinationUids =
|
|
41
|
+
const sourceUids = uidSetValues(codeSection[2]);
|
|
42
|
+
const destinationUids = uidSetValues(codeSection[3]);
|
|
29
43
|
if (sourceUids && destinationUids && sourceUids.length === destinationUids.length) {
|
|
30
44
|
map.uidMap = new Map(sourceUids.map((uid, i) => [uid, destinationUids[i]]));
|
|
31
45
|
}
|
|
@@ -86,28 +86,27 @@ async function compiler(response, options) {
|
|
|
86
86
|
.concat(response.command ? emitEntry(' ' + response.command) : []);
|
|
87
87
|
let val;
|
|
88
88
|
let lastType;
|
|
89
|
+
// Set right after the compiler writes "(" or "[" itself, so the first element inside gets no
|
|
90
|
+
// leading space
|
|
91
|
+
let afterOpener = false;
|
|
89
92
|
let walk = async (node, options) => {
|
|
90
93
|
options = options || {};
|
|
91
|
-
// Determine whether a space separator is needed before this node.
|
|
92
|
-
// Inspect the last byte written to decide context.
|
|
93
|
-
let lastRespEntry = resp.length && resp[resp.length - 1];
|
|
94
|
-
let lastRespByte = (lastRespEntry && lastRespEntry.length && lastRespEntry[lastRespEntry.length - 1]) || '';
|
|
95
|
-
if (typeof lastRespByte === 'number') {
|
|
96
|
-
lastRespByte = String.fromCharCode(lastRespByte);
|
|
97
|
-
}
|
|
98
94
|
// Add a space separator when:
|
|
99
95
|
// - The previous token was a LITERAL. Literal data ends exactly at its declared length, so
|
|
100
96
|
// a following token always needs an explicit separator, even though the last written byte
|
|
101
97
|
// is arbitrary literal content.
|
|
102
|
-
// - Otherwise: there is something written already (resp is not empty) and the
|
|
103
|
-
// not
|
|
98
|
+
// - Otherwise: there is something written already (resp is not empty) and the compiler did
|
|
99
|
+
// not just open a list or a section. This is tracked rather than read back from the last
|
|
100
|
+
// written byte: a token value can itself end in "(", "[" or "<" (an atom such as "X["),
|
|
101
|
+
// and suppressing the space after it would fuse it with the next argument.
|
|
104
102
|
// A sub-array element in a consecutive-list context never gets one (no space between
|
|
105
103
|
// adjacent lists).
|
|
106
|
-
if (lastType === 'LITERAL' || (!
|
|
104
|
+
if (lastType === 'LITERAL' || (!afterOpener && resp.length)) {
|
|
107
105
|
if (!options.subArray) {
|
|
108
106
|
resp.push(emitEntry(' '));
|
|
109
107
|
}
|
|
110
108
|
}
|
|
109
|
+
afterOpener = false;
|
|
111
110
|
if (node && node.buffer && !Buffer.isBuffer(node)) {
|
|
112
111
|
// mongodb binary
|
|
113
112
|
node = node.buffer;
|
|
@@ -115,6 +114,7 @@ async function compiler(response, options) {
|
|
|
115
114
|
if (Array.isArray(node)) {
|
|
116
115
|
lastType = 'LIST';
|
|
117
116
|
resp.push(emitEntry('('));
|
|
117
|
+
afterOpener = true;
|
|
118
118
|
// check if we need to skip separator WS between two arrays
|
|
119
119
|
let subArray = node.length > 1 && Array.isArray(node[0]);
|
|
120
120
|
for (let child of node) {
|
|
@@ -124,6 +124,7 @@ async function compiler(response, options) {
|
|
|
124
124
|
await walk(child, { subArray });
|
|
125
125
|
}
|
|
126
126
|
resp.push(emitEntry(')'));
|
|
127
|
+
afterOpener = false;
|
|
127
128
|
return;
|
|
128
129
|
}
|
|
129
130
|
if (!node && typeof node !== 'string' && typeof node !== 'number' && !Buffer.isBuffer(node)) {
|
|
@@ -199,19 +200,22 @@ async function compiler(response, options) {
|
|
|
199
200
|
// when logging: the incoming token parser accepts sequence-shaped tokens
|
|
200
201
|
// this strict grammar rejects (an ESEARCH set like "1:2:3", a folder
|
|
201
202
|
// name like "12:30:00"), and re-compiling a server response for the log
|
|
202
|
-
// or for error text must never throw.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
203
|
+
// or for error text must never throw. An empty or missing set is refused
|
|
204
|
+
// too: it would put nothing on the wire and leave the next argument in its
|
|
205
|
+
// place.
|
|
206
|
+
// Emitted raw: the validated alphabet cannot contain a line terminator, and
|
|
207
|
+
// re-scanning a potentially multi-megabyte set in the choke point would
|
|
208
|
+
// double the cost of exactly the sets this branch exists for
|
|
209
|
+
if (!isLogging) {
|
|
210
|
+
val = node.value === null || node.value === undefined ? '' : node.value.toString();
|
|
211
|
+
if (!isValidSequenceSet(val)) {
|
|
206
212
|
let error = new Error('Invalid sequence set value');
|
|
207
213
|
error.code = 'InvalidSequenceSet';
|
|
208
214
|
throw error;
|
|
209
215
|
}
|
|
216
|
+
resp.push(emitEntry(val, { raw: true }));
|
|
210
217
|
}
|
|
211
|
-
if (node.value) {
|
|
212
|
-
// raw: the validated alphabet cannot contain a line terminator, and
|
|
213
|
-
// re-scanning a potentially multi-megabyte set in the choke point
|
|
214
|
-
// would double the cost of exactly the sets this branch exists for
|
|
218
|
+
else if (node.value) {
|
|
215
219
|
resp.push(emitEntry(node.value, { raw: true }));
|
|
216
220
|
}
|
|
217
221
|
break;
|
|
@@ -252,10 +256,12 @@ async function compiler(response, options) {
|
|
|
252
256
|
// e.g., BODY[HEADER.FIELDS (Subject)] or BODY[1.MIME]
|
|
253
257
|
if (node.section) {
|
|
254
258
|
resp.push(emitEntry('['));
|
|
259
|
+
afterOpener = true;
|
|
255
260
|
for (let child of node.section) {
|
|
256
261
|
await walk(child);
|
|
257
262
|
}
|
|
258
263
|
resp.push(emitEntry(']'));
|
|
264
|
+
afterOpener = false;
|
|
259
265
|
}
|
|
260
266
|
// Partial range: emit <origin.length> after the section brackets. Coerced
|
|
261
267
|
// rather than joined as-is: this is the last token component written
|
|
@@ -278,7 +284,8 @@ async function compiler(response, options) {
|
|
|
278
284
|
respParts.push(resp);
|
|
279
285
|
}
|
|
280
286
|
const compiled = respParts.map(part => Buffer.concat(part));
|
|
281
|
-
|
|
287
|
+
// without asArray there is a single part, returned as is instead of copied
|
|
288
|
+
return asArray ? compiled : compiled.length === 1 ? compiled[0] : Buffer.concat(compiled);
|
|
282
289
|
}
|
|
283
290
|
exports.default = compiler;
|
|
284
291
|
module.exports = exports.default;
|
|
@@ -13,7 +13,6 @@ const STATE_NORMAL = 0x003;
|
|
|
13
13
|
const STATE_PARTIAL = 0x004;
|
|
14
14
|
const STATE_SEQUENCE = 0x005;
|
|
15
15
|
const STATE_STRING = 0x006;
|
|
16
|
-
const RE_DIGITS = /^\d+$/;
|
|
17
16
|
const RE_SINGLE_DIGIT = /^\d$/;
|
|
18
17
|
// Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...))))))
|
|
19
18
|
const MAX_NODE_DEPTH = 25;
|
|
@@ -175,6 +174,63 @@ class TokenParser {
|
|
|
175
174
|
i++;
|
|
176
175
|
}
|
|
177
176
|
};
|
|
177
|
+
// Whether chr is the closing delimiter of node: ")" ends a LIST, "]" ends a SECTION
|
|
178
|
+
const closesNode = (c, node) => !!node && ((c === ')' && node.type === 'LIST') || (c === ']' && node.type === 'SECTION'));
|
|
179
|
+
// Facts about the ATOM, SEQUENCE or PARTIAL token being read, kept up to date as its
|
|
180
|
+
// characters are appended. Reading them back from the value instead (value.at(-1),
|
|
181
|
+
// value.includes('*'), a regex test) makes V8 flatten the string built with += on every
|
|
182
|
+
// character, which is quadratic in the token length, and the server decides that length:
|
|
183
|
+
// a 600 KB ESEARCH result took seconds of event loop time
|
|
184
|
+
let tokenLength = 0;
|
|
185
|
+
let tokenLast = '';
|
|
186
|
+
let tokenPrev = '';
|
|
187
|
+
let tokenDigitsOnly = true;
|
|
188
|
+
let tokenHasStar = false;
|
|
189
|
+
let tokenHasDot = false;
|
|
190
|
+
const appendToToken = (c) => {
|
|
191
|
+
this.currentNode.value += c;
|
|
192
|
+
tokenLength++;
|
|
193
|
+
tokenPrev = tokenLast;
|
|
194
|
+
tokenLast = c;
|
|
195
|
+
tokenDigitsOnly = tokenDigitsOnly && RE_SINGLE_DIGIT.test(c);
|
|
196
|
+
tokenHasStar = tokenHasStar || c === '*';
|
|
197
|
+
tokenHasDot = tokenHasDot || c === '.';
|
|
198
|
+
};
|
|
199
|
+
// A digit-led token is only a guess at a sequence set. ":" and "," are ATOM-CHARs, so a
|
|
200
|
+
// server sends a mailbox name, keyword or label such as "2024:Q1" or "1,a" unquoted, and
|
|
201
|
+
// rejecting it would drop the whole response line: the mailbox missing from LIST, the
|
|
202
|
+
// message missing from FETCH. When the sequence grammar breaks, such a token is read on as
|
|
203
|
+
// an atom, and the atom rules decide whether the current character is acceptable. A token
|
|
204
|
+
// holding "*" can not be an atom ("*" is not an ATOM-CHAR), so it keeps the sequence errors
|
|
205
|
+
const continueAsAtom = () => {
|
|
206
|
+
this.currentNode.type = 'ATOM';
|
|
207
|
+
this.state = STATE_ATOM;
|
|
208
|
+
// read the current character again, in STATE_ATOM
|
|
209
|
+
i--;
|
|
210
|
+
};
|
|
211
|
+
// Starts the value of the current node as a new token, with its first character if any
|
|
212
|
+
const startToken = (first) => {
|
|
213
|
+
this.currentNode.value = '';
|
|
214
|
+
tokenLength = 0;
|
|
215
|
+
tokenLast = tokenPrev = '';
|
|
216
|
+
tokenDigitsOnly = true;
|
|
217
|
+
tokenHasStar = tokenHasDot = false;
|
|
218
|
+
if (first) {
|
|
219
|
+
appendToToken(first);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
// ImapStream supplies one buffer per literal marker it framed, {0} included. A marker
|
|
223
|
+
// without a buffer means the line and its literals do not match up, so fail with a parser
|
|
224
|
+
// error instead of reading a missing buffer or shifting every later literal by one
|
|
225
|
+
const takeLiteral = (literals) => {
|
|
226
|
+
if (!literals.length) {
|
|
227
|
+
let error = new Error(`Literal without data at position ${this.pos + i} [E35]`);
|
|
228
|
+
error.code = 'ParserError35';
|
|
229
|
+
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
return literals.shift();
|
|
233
|
+
};
|
|
178
234
|
// Any ATOM supported char starts a new Atom sequence, otherwise throw an error
|
|
179
235
|
// Allow \ as the first char for atom to support system flags
|
|
180
236
|
// Allow % to support LIST '' %
|
|
@@ -190,7 +246,7 @@ class TokenParser {
|
|
|
190
246
|
}
|
|
191
247
|
this.currentNode = this.createNode(this.currentNode, this.pos + i);
|
|
192
248
|
this.currentNode.type = 'ATOM';
|
|
193
|
-
|
|
249
|
+
startToken(chr);
|
|
194
250
|
this.state = STATE_ATOM;
|
|
195
251
|
};
|
|
196
252
|
for (i = 0, len = this.str.length; i < len; i++) {
|
|
@@ -245,12 +301,13 @@ class TokenParser {
|
|
|
245
301
|
if (this.str.charAt(i - 1) !== ']') {
|
|
246
302
|
this.currentNode = this.createNode(this.currentNode, this.pos + i);
|
|
247
303
|
this.currentNode.type = 'ATOM';
|
|
248
|
-
|
|
304
|
+
startToken(chr);
|
|
249
305
|
this.state = STATE_ATOM;
|
|
250
306
|
}
|
|
251
307
|
else {
|
|
252
308
|
this.currentNode = this.createNode(this.currentNode, this.pos + i);
|
|
253
309
|
this.currentNode.type = 'PARTIAL';
|
|
310
|
+
startToken();
|
|
254
311
|
this.state = STATE_PARTIAL;
|
|
255
312
|
this.currentNode.isClosed = false;
|
|
256
313
|
}
|
|
@@ -260,12 +317,14 @@ class TokenParser {
|
|
|
260
317
|
case '~': {
|
|
261
318
|
let nextChr = this.str.charAt(i + 1);
|
|
262
319
|
if (nextChr !== '{') {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
320
|
+
// '~' is an ATOM-CHAR itself, so when it does not start a literal8
|
|
321
|
+
// it starts an atom, including the one-character atom "~" that a
|
|
322
|
+
// space, the end of the line or a closing delimiter ends
|
|
323
|
+
if (imap_formal_syntax_js_1.default['ATOM-CHAR']().includes(nextChr) ||
|
|
324
|
+
nextChr === ' ' ||
|
|
325
|
+
nextChr === '' ||
|
|
326
|
+
closesNode(nextChr, this.currentNode)) {
|
|
327
|
+
startAtom();
|
|
269
328
|
break;
|
|
270
329
|
}
|
|
271
330
|
let error = new Error(`Unexpected literal8 marker at position ${this.pos + i} [E12]`);
|
|
@@ -291,7 +350,7 @@ class TokenParser {
|
|
|
291
350
|
case '*':
|
|
292
351
|
this.currentNode = this.createNode(this.currentNode, this.pos + i);
|
|
293
352
|
this.currentNode.type = 'SEQUENCE';
|
|
294
|
-
|
|
353
|
+
startToken(chr);
|
|
295
354
|
this.currentNode.isClosed = false;
|
|
296
355
|
this.state = STATE_SEQUENCE;
|
|
297
356
|
break;
|
|
@@ -372,8 +431,7 @@ class TokenParser {
|
|
|
372
431
|
break;
|
|
373
432
|
}
|
|
374
433
|
// ')' or ']' terminates the atom AND closes the enclosing LIST or SECTION
|
|
375
|
-
if (this.currentNode.parentNode
|
|
376
|
-
((chr === ')' && this.currentNode.parentNode.type === 'LIST') || (chr === ']' && this.currentNode.parentNode.type === 'SECTION'))) {
|
|
434
|
+
if (closesNode(chr, this.currentNode.parentNode)) {
|
|
377
435
|
this.currentNode.endPos = this.pos + i - 1;
|
|
378
436
|
this.currentNode = this.currentNode.parentNode;
|
|
379
437
|
this.currentNode.isClosed = true;
|
|
@@ -409,14 +467,16 @@ class TokenParser {
|
|
|
409
467
|
}
|
|
410
468
|
// If the atom so far is all digits and we see ',' or ':', it is actually
|
|
411
469
|
// a sequence set (e.g., "1:5" or "1,3,5"), so reclassify and switch state
|
|
412
|
-
if ((chr === ',' || chr === ':') &&
|
|
470
|
+
if ((chr === ',' || chr === ':') && tokenLength && tokenDigitsOnly) {
|
|
413
471
|
this.currentNode.type = 'SEQUENCE';
|
|
414
472
|
this.currentNode.isClosed = true;
|
|
415
473
|
this.state = STATE_SEQUENCE;
|
|
416
474
|
}
|
|
417
475
|
// [ starts a section group for this element
|
|
418
476
|
// Allowed only for selected elements, otherwise falls through to regular ATOM processing
|
|
419
|
-
if (chr === '[' &&
|
|
477
|
+
if (chr === '[' &&
|
|
478
|
+
tokenLength <= 11 &&
|
|
479
|
+
['BODY', 'BODY.PEEK', 'BINARY', 'BINARY.PEEK'].includes(this.currentNode.value.toUpperCase())) {
|
|
420
480
|
this.currentNode.endPos = this.pos + i;
|
|
421
481
|
this.currentNode = this.createNode(this.currentNode.parentNode, this.pos + i);
|
|
422
482
|
this.currentNode.type = 'SECTION';
|
|
@@ -441,7 +501,7 @@ class TokenParser {
|
|
|
441
501
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
442
502
|
throw error;
|
|
443
503
|
}
|
|
444
|
-
|
|
504
|
+
appendToToken(chr);
|
|
445
505
|
break;
|
|
446
506
|
case STATE_STRING:
|
|
447
507
|
// DQUOTE ends the string sequence
|
|
@@ -468,7 +528,7 @@ class TokenParser {
|
|
|
468
528
|
break;
|
|
469
529
|
case STATE_PARTIAL:
|
|
470
530
|
if (chr === '>') {
|
|
471
|
-
if (
|
|
531
|
+
if (tokenLast === '.') {
|
|
472
532
|
let error = new Error(`Unexpected end of partial at position ${this.pos + i} [E19]`);
|
|
473
533
|
error.code = 'ParserError19';
|
|
474
534
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
@@ -481,7 +541,7 @@ class TokenParser {
|
|
|
481
541
|
checkSP();
|
|
482
542
|
break;
|
|
483
543
|
}
|
|
484
|
-
if (chr === '.' && (
|
|
544
|
+
if (chr === '.' && (!tokenLength || tokenHasDot)) {
|
|
485
545
|
let error = new Error(`Unexpected partial separator . at position ${this.pos + i} [E20]`);
|
|
486
546
|
error.code = 'ParserError20';
|
|
487
547
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
@@ -493,13 +553,13 @@ class TokenParser {
|
|
|
493
553
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
494
554
|
throw error;
|
|
495
555
|
}
|
|
496
|
-
if (
|
|
556
|
+
if (tokenLast === '0' && (tokenLength === 1 || tokenPrev === '.') && chr !== '.') {
|
|
497
557
|
let error = new Error(`Invalid partial at position ${this.pos + i} [E22: ${JSON.stringify(chr)}]`);
|
|
498
558
|
error.code = 'ParserError22';
|
|
499
559
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
500
560
|
throw error;
|
|
501
561
|
}
|
|
502
|
-
|
|
562
|
+
appendToToken(chr);
|
|
503
563
|
break;
|
|
504
564
|
case STATE_LITERAL:
|
|
505
565
|
if (this.currentNode.started) {
|
|
@@ -550,12 +610,10 @@ class TokenParser {
|
|
|
550
610
|
if (!this.currentNode.literalLength) {
|
|
551
611
|
// special case where literal content length is 0
|
|
552
612
|
// close the node right away, do not wait for additional input
|
|
553
|
-
if (this.options.literals
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
// with their markers instead of shifting by one
|
|
558
|
-
this.currentNode.value = this.options.literals.shift();
|
|
613
|
+
if (this.options.literals) {
|
|
614
|
+
// consume the queue entry of the {0} marker too, so later
|
|
615
|
+
// literals in the same response stay aligned with their markers
|
|
616
|
+
this.currentNode.value = takeLiteral(this.options.literals);
|
|
559
617
|
}
|
|
560
618
|
this.currentNode.endPos = this.pos + i;
|
|
561
619
|
this.currentNode.isClosed = true;
|
|
@@ -565,7 +623,7 @@ class TokenParser {
|
|
|
565
623
|
}
|
|
566
624
|
else if (this.options.literals) {
|
|
567
625
|
// use the next precached literal values
|
|
568
|
-
this.currentNode.value = this.options.literals
|
|
626
|
+
this.currentNode.value = takeLiteral(this.options.literals);
|
|
569
627
|
// only APPEND arguments are kept as Buffers
|
|
570
628
|
/*
|
|
571
629
|
if ((this.parent.command || '').toString().toUpperCase() !== 'APPEND') {
|
|
@@ -620,19 +678,24 @@ class TokenParser {
|
|
|
620
678
|
}
|
|
621
679
|
this.currentNode.literalLength = (this.currentNode.literalLength || '') + chr;
|
|
622
680
|
break;
|
|
623
|
-
case STATE_SEQUENCE:
|
|
624
|
-
// space
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
681
|
+
case STATE_SEQUENCE: {
|
|
682
|
+
// A space ends the sequence set, and so does the closing delimiter of the
|
|
683
|
+
// enclosing list or section ("PARTIAL (1:100 5,7,9)", "[COPYUID 1 1:3 4,5]")
|
|
684
|
+
let closesParent = closesNode(chr, this.currentNode.parentNode);
|
|
685
|
+
if (chr === ' ' || closesParent) {
|
|
686
|
+
if (!RE_SINGLE_DIGIT.test(tokenLast) && tokenLast !== '*') {
|
|
687
|
+
if (!tokenHasStar) {
|
|
688
|
+
// a dangling separator, "10:" or "1,"
|
|
689
|
+
continueAsAtom();
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
let error = new Error(`Unexpected end of sequence at position ${this.pos + i} [E27: ${JSON.stringify(chr)}]`);
|
|
628
693
|
error.code = 'ParserError27';
|
|
629
694
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
630
695
|
throw error;
|
|
631
696
|
}
|
|
632
|
-
if (this.currentNode.value !== '*' &&
|
|
633
|
-
this.
|
|
634
|
-
this.currentNode.value.at(-2) !== ':') {
|
|
635
|
-
let error = new Error(`Unexpected whitespace at position ${this.pos + i} [E28]`);
|
|
697
|
+
if (this.currentNode.value !== '*' && tokenLast === '*' && tokenPrev !== ':') {
|
|
698
|
+
let error = new Error(`Unexpected end of sequence at position ${this.pos + i} [E28: ${JSON.stringify(chr)}]`);
|
|
636
699
|
error.code = 'ParserError28';
|
|
637
700
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
638
701
|
throw error;
|
|
@@ -641,20 +704,20 @@ class TokenParser {
|
|
|
641
704
|
this.currentNode.endPos = this.pos + i - 1;
|
|
642
705
|
this.currentNode = this.currentNode.parentNode;
|
|
643
706
|
this.state = STATE_NORMAL;
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
this.currentNode.endPos = this.pos + i;
|
|
651
|
-
this.currentNode = this.currentNode.parentNode;
|
|
652
|
-
this.state = STATE_NORMAL;
|
|
653
|
-
checkSP();
|
|
707
|
+
if (closesParent) {
|
|
708
|
+
this.currentNode.isClosed = true;
|
|
709
|
+
this.currentNode.endPos = this.pos + i;
|
|
710
|
+
this.currentNode = this.currentNode.parentNode;
|
|
711
|
+
checkSP();
|
|
712
|
+
}
|
|
654
713
|
break;
|
|
655
714
|
}
|
|
656
715
|
if (chr === ':') {
|
|
657
|
-
if (!RE_SINGLE_DIGIT.test(
|
|
716
|
+
if (!RE_SINGLE_DIGIT.test(tokenLast) && tokenLast !== '*') {
|
|
717
|
+
if (!tokenHasStar) {
|
|
718
|
+
continueAsAtom();
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
658
721
|
let error = new Error(`Unexpected range separator : at position ${this.pos + i} [E29]`);
|
|
659
722
|
error.code = 'ParserError29';
|
|
660
723
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
@@ -662,7 +725,7 @@ class TokenParser {
|
|
|
662
725
|
}
|
|
663
726
|
}
|
|
664
727
|
else if (chr === '*') {
|
|
665
|
-
if (![',', ':'].includes(
|
|
728
|
+
if (![',', ':'].includes(tokenLast)) {
|
|
666
729
|
let error = new Error(`Unexpected range wildcard at position ${this.pos + i} [E30]`);
|
|
667
730
|
error.code = 'ParserError30';
|
|
668
731
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
@@ -670,13 +733,17 @@ class TokenParser {
|
|
|
670
733
|
}
|
|
671
734
|
}
|
|
672
735
|
else if (chr === ',') {
|
|
673
|
-
if (!RE_SINGLE_DIGIT.test(
|
|
736
|
+
if (!RE_SINGLE_DIGIT.test(tokenLast) && tokenLast !== '*') {
|
|
737
|
+
if (!tokenHasStar) {
|
|
738
|
+
continueAsAtom();
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
674
741
|
let error = new Error(`Unexpected sequence separator , at position ${this.pos + i} [E31]`);
|
|
675
742
|
error.code = 'ParserError31';
|
|
676
743
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
677
744
|
throw error;
|
|
678
745
|
}
|
|
679
|
-
if (
|
|
746
|
+
if (tokenLast === '*' && tokenPrev !== ':') {
|
|
680
747
|
let error = new Error(`Unexpected sequence separator , at position ${this.pos + i} [E32]`);
|
|
681
748
|
error.code = 'ParserError32';
|
|
682
749
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
@@ -684,21 +751,30 @@ class TokenParser {
|
|
|
684
751
|
}
|
|
685
752
|
}
|
|
686
753
|
else if (!RE_SINGLE_DIGIT.test(chr)) {
|
|
754
|
+
if (!tokenHasStar) {
|
|
755
|
+
continueAsAtom();
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
687
758
|
let error = new Error(`Unexpected char at position ${this.pos + i} [E33: ${JSON.stringify(chr)}]`);
|
|
688
759
|
error.code = 'ParserError33';
|
|
689
760
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
690
761
|
throw error;
|
|
691
762
|
}
|
|
692
|
-
if (RE_SINGLE_DIGIT.test(chr) &&
|
|
763
|
+
if (RE_SINGLE_DIGIT.test(chr) && tokenLast === '*') {
|
|
693
764
|
let error = new Error(`Unexpected number at position ${this.pos + i} [E34: ${JSON.stringify(chr)}]`);
|
|
694
765
|
error.code = 'ParserError34';
|
|
695
766
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
696
767
|
throw error;
|
|
697
768
|
}
|
|
698
|
-
|
|
769
|
+
appendToToken(chr);
|
|
699
770
|
break;
|
|
771
|
+
}
|
|
700
772
|
}
|
|
701
773
|
}
|
|
774
|
+
// the same applies to a digit-led token that ends the input on a dangling separator ("10:")
|
|
775
|
+
if (this.state === STATE_SEQUENCE && (tokenLast === ':' || tokenLast === ',') && !tokenHasStar) {
|
|
776
|
+
this.currentNode.type = 'ATOM';
|
|
777
|
+
}
|
|
702
778
|
}
|
|
703
779
|
}
|
|
704
780
|
exports.TokenParser = TokenParser;
|
package/dist/cjs/package-info.js
CHANGED
|
@@ -389,8 +389,12 @@ const searchCompiler = (connection, query) => {
|
|
|
389
389
|
}
|
|
390
390
|
break;
|
|
391
391
|
// NOT operator
|
|
392
|
+
// A falsy operand means no NOT clause (`not: cond && {...}`). Any other one goes
|
|
393
|
+
// through walkOperand(), which refuses a value that is not a query object or
|
|
394
|
+
// compiles to nothing: dropping it would turn the filter into a search that
|
|
395
|
+
// matches everything
|
|
392
396
|
case 'NOT':
|
|
393
|
-
if (params[term]
|
|
397
|
+
if (params[term]) {
|
|
394
398
|
attributes.push({ type: 'ATOM', value: 'NOT' });
|
|
395
399
|
walkOperand('NOT', params[term]);
|
|
396
400
|
}
|
|
@@ -403,8 +407,8 @@ const searchCompiler = (connection, query) => {
|
|
|
403
407
|
}
|
|
404
408
|
// Single element - just process it directly
|
|
405
409
|
if (params[term].length === 1) {
|
|
406
|
-
if (
|
|
407
|
-
|
|
410
|
+
if (params[term][0]) {
|
|
411
|
+
walkOperand('OR', params[term][0]);
|
|
408
412
|
}
|
|
409
413
|
break;
|
|
410
414
|
}
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import { expandRange, parseBigIntValue } from '../tools.js';
|
|
2
|
+
/**
|
|
3
|
+
* Whether a value is a complete uid-set (RFC 4315): comma separated UIDs and UID ranges. The UID
|
|
4
|
+
* map pairs the source and destination sets by position, so a set with a malformed element must
|
|
5
|
+
* be refused as a whole: skipping the element, as expandRange() does, would shift every UID after
|
|
6
|
+
* it onto the wrong counterpart. Checked per element, as a whole-set regex with an unbounded
|
|
7
|
+
* repeat can overflow the regex engine on very long sets.
|
|
8
|
+
*/
|
|
9
|
+
const UID_SET_ELEMENT = /^\d+(:\d+)?$/;
|
|
10
|
+
const isUidSet = (value) => typeof value === 'string' && value.split(',').every(part => UID_SET_ELEMENT.test(part));
|
|
11
|
+
// The UIDs of a uid-set attribute, or false when the attribute is not a complete uid-set
|
|
12
|
+
const uidSetValues = (attribute) => {
|
|
13
|
+
const value = attribute && attribute.value;
|
|
14
|
+
return isUidSet(value) ? expandRange(value) : false;
|
|
15
|
+
};
|
|
2
16
|
/**
|
|
3
17
|
* Parses COPYUID response code from an IMAP response (RFC 4315).
|
|
4
18
|
* Used by both COPY and MOVE commands to extract the UID mapping
|
|
@@ -21,8 +35,8 @@ export function parseCopyUid(response, map) {
|
|
|
21
35
|
if (uidValidity !== false) {
|
|
22
36
|
map.uidValidity = uidValidity;
|
|
23
37
|
}
|
|
24
|
-
const sourceUids =
|
|
25
|
-
const destinationUids =
|
|
38
|
+
const sourceUids = uidSetValues(codeSection[2]);
|
|
39
|
+
const destinationUids = uidSetValues(codeSection[3]);
|
|
26
40
|
if (sourceUids && destinationUids && sourceUids.length === destinationUids.length) {
|
|
27
41
|
map.uidMap = new Map(sourceUids.map((uid, i) => [uid, destinationUids[i]]));
|
|
28
42
|
}
|
|
@@ -81,28 +81,27 @@ async function compiler(response, options) {
|
|
|
81
81
|
.concat(response.command ? emitEntry(' ' + response.command) : []);
|
|
82
82
|
let val;
|
|
83
83
|
let lastType;
|
|
84
|
+
// Set right after the compiler writes "(" or "[" itself, so the first element inside gets no
|
|
85
|
+
// leading space
|
|
86
|
+
let afterOpener = false;
|
|
84
87
|
let walk = async (node, options) => {
|
|
85
88
|
options = options || {};
|
|
86
|
-
// Determine whether a space separator is needed before this node.
|
|
87
|
-
// Inspect the last byte written to decide context.
|
|
88
|
-
let lastRespEntry = resp.length && resp[resp.length - 1];
|
|
89
|
-
let lastRespByte = (lastRespEntry && lastRespEntry.length && lastRespEntry[lastRespEntry.length - 1]) || '';
|
|
90
|
-
if (typeof lastRespByte === 'number') {
|
|
91
|
-
lastRespByte = String.fromCharCode(lastRespByte);
|
|
92
|
-
}
|
|
93
89
|
// Add a space separator when:
|
|
94
90
|
// - The previous token was a LITERAL. Literal data ends exactly at its declared length, so
|
|
95
91
|
// a following token always needs an explicit separator, even though the last written byte
|
|
96
92
|
// is arbitrary literal content.
|
|
97
|
-
// - Otherwise: there is something written already (resp is not empty) and the
|
|
98
|
-
// not
|
|
93
|
+
// - Otherwise: there is something written already (resp is not empty) and the compiler did
|
|
94
|
+
// not just open a list or a section. This is tracked rather than read back from the last
|
|
95
|
+
// written byte: a token value can itself end in "(", "[" or "<" (an atom such as "X["),
|
|
96
|
+
// and suppressing the space after it would fuse it with the next argument.
|
|
99
97
|
// A sub-array element in a consecutive-list context never gets one (no space between
|
|
100
98
|
// adjacent lists).
|
|
101
|
-
if (lastType === 'LITERAL' || (!
|
|
99
|
+
if (lastType === 'LITERAL' || (!afterOpener && resp.length)) {
|
|
102
100
|
if (!options.subArray) {
|
|
103
101
|
resp.push(emitEntry(' '));
|
|
104
102
|
}
|
|
105
103
|
}
|
|
104
|
+
afterOpener = false;
|
|
106
105
|
if (node && node.buffer && !Buffer.isBuffer(node)) {
|
|
107
106
|
// mongodb binary
|
|
108
107
|
node = node.buffer;
|
|
@@ -110,6 +109,7 @@ async function compiler(response, options) {
|
|
|
110
109
|
if (Array.isArray(node)) {
|
|
111
110
|
lastType = 'LIST';
|
|
112
111
|
resp.push(emitEntry('('));
|
|
112
|
+
afterOpener = true;
|
|
113
113
|
// check if we need to skip separator WS between two arrays
|
|
114
114
|
let subArray = node.length > 1 && Array.isArray(node[0]);
|
|
115
115
|
for (let child of node) {
|
|
@@ -119,6 +119,7 @@ async function compiler(response, options) {
|
|
|
119
119
|
await walk(child, { subArray });
|
|
120
120
|
}
|
|
121
121
|
resp.push(emitEntry(')'));
|
|
122
|
+
afterOpener = false;
|
|
122
123
|
return;
|
|
123
124
|
}
|
|
124
125
|
if (!node && typeof node !== 'string' && typeof node !== 'number' && !Buffer.isBuffer(node)) {
|
|
@@ -194,19 +195,22 @@ async function compiler(response, options) {
|
|
|
194
195
|
// when logging: the incoming token parser accepts sequence-shaped tokens
|
|
195
196
|
// this strict grammar rejects (an ESEARCH set like "1:2:3", a folder
|
|
196
197
|
// name like "12:30:00"), and re-compiling a server response for the log
|
|
197
|
-
// or for error text must never throw.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
198
|
+
// or for error text must never throw. An empty or missing set is refused
|
|
199
|
+
// too: it would put nothing on the wire and leave the next argument in its
|
|
200
|
+
// place.
|
|
201
|
+
// Emitted raw: the validated alphabet cannot contain a line terminator, and
|
|
202
|
+
// re-scanning a potentially multi-megabyte set in the choke point would
|
|
203
|
+
// double the cost of exactly the sets this branch exists for
|
|
204
|
+
if (!isLogging) {
|
|
205
|
+
val = node.value === null || node.value === undefined ? '' : node.value.toString();
|
|
206
|
+
if (!isValidSequenceSet(val)) {
|
|
201
207
|
let error = new Error('Invalid sequence set value');
|
|
202
208
|
error.code = 'InvalidSequenceSet';
|
|
203
209
|
throw error;
|
|
204
210
|
}
|
|
211
|
+
resp.push(emitEntry(val, { raw: true }));
|
|
205
212
|
}
|
|
206
|
-
if (node.value) {
|
|
207
|
-
// raw: the validated alphabet cannot contain a line terminator, and
|
|
208
|
-
// re-scanning a potentially multi-megabyte set in the choke point
|
|
209
|
-
// would double the cost of exactly the sets this branch exists for
|
|
213
|
+
else if (node.value) {
|
|
210
214
|
resp.push(emitEntry(node.value, { raw: true }));
|
|
211
215
|
}
|
|
212
216
|
break;
|
|
@@ -247,10 +251,12 @@ async function compiler(response, options) {
|
|
|
247
251
|
// e.g., BODY[HEADER.FIELDS (Subject)] or BODY[1.MIME]
|
|
248
252
|
if (node.section) {
|
|
249
253
|
resp.push(emitEntry('['));
|
|
254
|
+
afterOpener = true;
|
|
250
255
|
for (let child of node.section) {
|
|
251
256
|
await walk(child);
|
|
252
257
|
}
|
|
253
258
|
resp.push(emitEntry(']'));
|
|
259
|
+
afterOpener = false;
|
|
254
260
|
}
|
|
255
261
|
// Partial range: emit <origin.length> after the section brackets. Coerced
|
|
256
262
|
// rather than joined as-is: this is the last token component written
|
|
@@ -273,6 +279,7 @@ async function compiler(response, options) {
|
|
|
273
279
|
respParts.push(resp);
|
|
274
280
|
}
|
|
275
281
|
const compiled = respParts.map(part => Buffer.concat(part));
|
|
276
|
-
|
|
282
|
+
// without asArray there is a single part, returned as is instead of copied
|
|
283
|
+
return asArray ? compiled : compiled.length === 1 ? compiled[0] : Buffer.concat(compiled);
|
|
277
284
|
}
|
|
278
285
|
export default compiler;
|
|
@@ -7,7 +7,6 @@ const STATE_NORMAL = 0x003;
|
|
|
7
7
|
const STATE_PARTIAL = 0x004;
|
|
8
8
|
const STATE_SEQUENCE = 0x005;
|
|
9
9
|
const STATE_STRING = 0x006;
|
|
10
|
-
const RE_DIGITS = /^\d+$/;
|
|
11
10
|
const RE_SINGLE_DIGIT = /^\d$/;
|
|
12
11
|
// Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...))))))
|
|
13
12
|
const MAX_NODE_DEPTH = 25;
|
|
@@ -169,6 +168,63 @@ export class TokenParser {
|
|
|
169
168
|
i++;
|
|
170
169
|
}
|
|
171
170
|
};
|
|
171
|
+
// Whether chr is the closing delimiter of node: ")" ends a LIST, "]" ends a SECTION
|
|
172
|
+
const closesNode = (c, node) => !!node && ((c === ')' && node.type === 'LIST') || (c === ']' && node.type === 'SECTION'));
|
|
173
|
+
// Facts about the ATOM, SEQUENCE or PARTIAL token being read, kept up to date as its
|
|
174
|
+
// characters are appended. Reading them back from the value instead (value.at(-1),
|
|
175
|
+
// value.includes('*'), a regex test) makes V8 flatten the string built with += on every
|
|
176
|
+
// character, which is quadratic in the token length, and the server decides that length:
|
|
177
|
+
// a 600 KB ESEARCH result took seconds of event loop time
|
|
178
|
+
let tokenLength = 0;
|
|
179
|
+
let tokenLast = '';
|
|
180
|
+
let tokenPrev = '';
|
|
181
|
+
let tokenDigitsOnly = true;
|
|
182
|
+
let tokenHasStar = false;
|
|
183
|
+
let tokenHasDot = false;
|
|
184
|
+
const appendToToken = (c) => {
|
|
185
|
+
this.currentNode.value += c;
|
|
186
|
+
tokenLength++;
|
|
187
|
+
tokenPrev = tokenLast;
|
|
188
|
+
tokenLast = c;
|
|
189
|
+
tokenDigitsOnly = tokenDigitsOnly && RE_SINGLE_DIGIT.test(c);
|
|
190
|
+
tokenHasStar = tokenHasStar || c === '*';
|
|
191
|
+
tokenHasDot = tokenHasDot || c === '.';
|
|
192
|
+
};
|
|
193
|
+
// A digit-led token is only a guess at a sequence set. ":" and "," are ATOM-CHARs, so a
|
|
194
|
+
// server sends a mailbox name, keyword or label such as "2024:Q1" or "1,a" unquoted, and
|
|
195
|
+
// rejecting it would drop the whole response line: the mailbox missing from LIST, the
|
|
196
|
+
// message missing from FETCH. When the sequence grammar breaks, such a token is read on as
|
|
197
|
+
// an atom, and the atom rules decide whether the current character is acceptable. A token
|
|
198
|
+
// holding "*" can not be an atom ("*" is not an ATOM-CHAR), so it keeps the sequence errors
|
|
199
|
+
const continueAsAtom = () => {
|
|
200
|
+
this.currentNode.type = 'ATOM';
|
|
201
|
+
this.state = STATE_ATOM;
|
|
202
|
+
// read the current character again, in STATE_ATOM
|
|
203
|
+
i--;
|
|
204
|
+
};
|
|
205
|
+
// Starts the value of the current node as a new token, with its first character if any
|
|
206
|
+
const startToken = (first) => {
|
|
207
|
+
this.currentNode.value = '';
|
|
208
|
+
tokenLength = 0;
|
|
209
|
+
tokenLast = tokenPrev = '';
|
|
210
|
+
tokenDigitsOnly = true;
|
|
211
|
+
tokenHasStar = tokenHasDot = false;
|
|
212
|
+
if (first) {
|
|
213
|
+
appendToToken(first);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
// ImapStream supplies one buffer per literal marker it framed, {0} included. A marker
|
|
217
|
+
// without a buffer means the line and its literals do not match up, so fail with a parser
|
|
218
|
+
// error instead of reading a missing buffer or shifting every later literal by one
|
|
219
|
+
const takeLiteral = (literals) => {
|
|
220
|
+
if (!literals.length) {
|
|
221
|
+
let error = new Error(`Literal without data at position ${this.pos + i} [E35]`);
|
|
222
|
+
error.code = 'ParserError35';
|
|
223
|
+
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
return literals.shift();
|
|
227
|
+
};
|
|
172
228
|
// Any ATOM supported char starts a new Atom sequence, otherwise throw an error
|
|
173
229
|
// Allow \ as the first char for atom to support system flags
|
|
174
230
|
// Allow % to support LIST '' %
|
|
@@ -184,7 +240,7 @@ export class TokenParser {
|
|
|
184
240
|
}
|
|
185
241
|
this.currentNode = this.createNode(this.currentNode, this.pos + i);
|
|
186
242
|
this.currentNode.type = 'ATOM';
|
|
187
|
-
|
|
243
|
+
startToken(chr);
|
|
188
244
|
this.state = STATE_ATOM;
|
|
189
245
|
};
|
|
190
246
|
for (i = 0, len = this.str.length; i < len; i++) {
|
|
@@ -239,12 +295,13 @@ export class TokenParser {
|
|
|
239
295
|
if (this.str.charAt(i - 1) !== ']') {
|
|
240
296
|
this.currentNode = this.createNode(this.currentNode, this.pos + i);
|
|
241
297
|
this.currentNode.type = 'ATOM';
|
|
242
|
-
|
|
298
|
+
startToken(chr);
|
|
243
299
|
this.state = STATE_ATOM;
|
|
244
300
|
}
|
|
245
301
|
else {
|
|
246
302
|
this.currentNode = this.createNode(this.currentNode, this.pos + i);
|
|
247
303
|
this.currentNode.type = 'PARTIAL';
|
|
304
|
+
startToken();
|
|
248
305
|
this.state = STATE_PARTIAL;
|
|
249
306
|
this.currentNode.isClosed = false;
|
|
250
307
|
}
|
|
@@ -254,12 +311,14 @@ export class TokenParser {
|
|
|
254
311
|
case '~': {
|
|
255
312
|
let nextChr = this.str.charAt(i + 1);
|
|
256
313
|
if (nextChr !== '{') {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
314
|
+
// '~' is an ATOM-CHAR itself, so when it does not start a literal8
|
|
315
|
+
// it starts an atom, including the one-character atom "~" that a
|
|
316
|
+
// space, the end of the line or a closing delimiter ends
|
|
317
|
+
if (imapFormalSyntax['ATOM-CHAR']().includes(nextChr) ||
|
|
318
|
+
nextChr === ' ' ||
|
|
319
|
+
nextChr === '' ||
|
|
320
|
+
closesNode(nextChr, this.currentNode)) {
|
|
321
|
+
startAtom();
|
|
263
322
|
break;
|
|
264
323
|
}
|
|
265
324
|
let error = new Error(`Unexpected literal8 marker at position ${this.pos + i} [E12]`);
|
|
@@ -285,7 +344,7 @@ export class TokenParser {
|
|
|
285
344
|
case '*':
|
|
286
345
|
this.currentNode = this.createNode(this.currentNode, this.pos + i);
|
|
287
346
|
this.currentNode.type = 'SEQUENCE';
|
|
288
|
-
|
|
347
|
+
startToken(chr);
|
|
289
348
|
this.currentNode.isClosed = false;
|
|
290
349
|
this.state = STATE_SEQUENCE;
|
|
291
350
|
break;
|
|
@@ -366,8 +425,7 @@ export class TokenParser {
|
|
|
366
425
|
break;
|
|
367
426
|
}
|
|
368
427
|
// ')' or ']' terminates the atom AND closes the enclosing LIST or SECTION
|
|
369
|
-
if (this.currentNode.parentNode
|
|
370
|
-
((chr === ')' && this.currentNode.parentNode.type === 'LIST') || (chr === ']' && this.currentNode.parentNode.type === 'SECTION'))) {
|
|
428
|
+
if (closesNode(chr, this.currentNode.parentNode)) {
|
|
371
429
|
this.currentNode.endPos = this.pos + i - 1;
|
|
372
430
|
this.currentNode = this.currentNode.parentNode;
|
|
373
431
|
this.currentNode.isClosed = true;
|
|
@@ -403,14 +461,16 @@ export class TokenParser {
|
|
|
403
461
|
}
|
|
404
462
|
// If the atom so far is all digits and we see ',' or ':', it is actually
|
|
405
463
|
// a sequence set (e.g., "1:5" or "1,3,5"), so reclassify and switch state
|
|
406
|
-
if ((chr === ',' || chr === ':') &&
|
|
464
|
+
if ((chr === ',' || chr === ':') && tokenLength && tokenDigitsOnly) {
|
|
407
465
|
this.currentNode.type = 'SEQUENCE';
|
|
408
466
|
this.currentNode.isClosed = true;
|
|
409
467
|
this.state = STATE_SEQUENCE;
|
|
410
468
|
}
|
|
411
469
|
// [ starts a section group for this element
|
|
412
470
|
// Allowed only for selected elements, otherwise falls through to regular ATOM processing
|
|
413
|
-
if (chr === '[' &&
|
|
471
|
+
if (chr === '[' &&
|
|
472
|
+
tokenLength <= 11 &&
|
|
473
|
+
['BODY', 'BODY.PEEK', 'BINARY', 'BINARY.PEEK'].includes(this.currentNode.value.toUpperCase())) {
|
|
414
474
|
this.currentNode.endPos = this.pos + i;
|
|
415
475
|
this.currentNode = this.createNode(this.currentNode.parentNode, this.pos + i);
|
|
416
476
|
this.currentNode.type = 'SECTION';
|
|
@@ -435,7 +495,7 @@ export class TokenParser {
|
|
|
435
495
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
436
496
|
throw error;
|
|
437
497
|
}
|
|
438
|
-
|
|
498
|
+
appendToToken(chr);
|
|
439
499
|
break;
|
|
440
500
|
case STATE_STRING:
|
|
441
501
|
// DQUOTE ends the string sequence
|
|
@@ -462,7 +522,7 @@ export class TokenParser {
|
|
|
462
522
|
break;
|
|
463
523
|
case STATE_PARTIAL:
|
|
464
524
|
if (chr === '>') {
|
|
465
|
-
if (
|
|
525
|
+
if (tokenLast === '.') {
|
|
466
526
|
let error = new Error(`Unexpected end of partial at position ${this.pos + i} [E19]`);
|
|
467
527
|
error.code = 'ParserError19';
|
|
468
528
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
@@ -475,7 +535,7 @@ export class TokenParser {
|
|
|
475
535
|
checkSP();
|
|
476
536
|
break;
|
|
477
537
|
}
|
|
478
|
-
if (chr === '.' && (
|
|
538
|
+
if (chr === '.' && (!tokenLength || tokenHasDot)) {
|
|
479
539
|
let error = new Error(`Unexpected partial separator . at position ${this.pos + i} [E20]`);
|
|
480
540
|
error.code = 'ParserError20';
|
|
481
541
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
@@ -487,13 +547,13 @@ export class TokenParser {
|
|
|
487
547
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
488
548
|
throw error;
|
|
489
549
|
}
|
|
490
|
-
if (
|
|
550
|
+
if (tokenLast === '0' && (tokenLength === 1 || tokenPrev === '.') && chr !== '.') {
|
|
491
551
|
let error = new Error(`Invalid partial at position ${this.pos + i} [E22: ${JSON.stringify(chr)}]`);
|
|
492
552
|
error.code = 'ParserError22';
|
|
493
553
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
494
554
|
throw error;
|
|
495
555
|
}
|
|
496
|
-
|
|
556
|
+
appendToToken(chr);
|
|
497
557
|
break;
|
|
498
558
|
case STATE_LITERAL:
|
|
499
559
|
if (this.currentNode.started) {
|
|
@@ -544,12 +604,10 @@ export class TokenParser {
|
|
|
544
604
|
if (!this.currentNode.literalLength) {
|
|
545
605
|
// special case where literal content length is 0
|
|
546
606
|
// close the node right away, do not wait for additional input
|
|
547
|
-
if (this.options.literals
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
// with their markers instead of shifting by one
|
|
552
|
-
this.currentNode.value = this.options.literals.shift();
|
|
607
|
+
if (this.options.literals) {
|
|
608
|
+
// consume the queue entry of the {0} marker too, so later
|
|
609
|
+
// literals in the same response stay aligned with their markers
|
|
610
|
+
this.currentNode.value = takeLiteral(this.options.literals);
|
|
553
611
|
}
|
|
554
612
|
this.currentNode.endPos = this.pos + i;
|
|
555
613
|
this.currentNode.isClosed = true;
|
|
@@ -559,7 +617,7 @@ export class TokenParser {
|
|
|
559
617
|
}
|
|
560
618
|
else if (this.options.literals) {
|
|
561
619
|
// use the next precached literal values
|
|
562
|
-
this.currentNode.value = this.options.literals
|
|
620
|
+
this.currentNode.value = takeLiteral(this.options.literals);
|
|
563
621
|
// only APPEND arguments are kept as Buffers
|
|
564
622
|
/*
|
|
565
623
|
if ((this.parent.command || '').toString().toUpperCase() !== 'APPEND') {
|
|
@@ -614,19 +672,24 @@ export class TokenParser {
|
|
|
614
672
|
}
|
|
615
673
|
this.currentNode.literalLength = (this.currentNode.literalLength || '') + chr;
|
|
616
674
|
break;
|
|
617
|
-
case STATE_SEQUENCE:
|
|
618
|
-
// space
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
675
|
+
case STATE_SEQUENCE: {
|
|
676
|
+
// A space ends the sequence set, and so does the closing delimiter of the
|
|
677
|
+
// enclosing list or section ("PARTIAL (1:100 5,7,9)", "[COPYUID 1 1:3 4,5]")
|
|
678
|
+
let closesParent = closesNode(chr, this.currentNode.parentNode);
|
|
679
|
+
if (chr === ' ' || closesParent) {
|
|
680
|
+
if (!RE_SINGLE_DIGIT.test(tokenLast) && tokenLast !== '*') {
|
|
681
|
+
if (!tokenHasStar) {
|
|
682
|
+
// a dangling separator, "10:" or "1,"
|
|
683
|
+
continueAsAtom();
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
let error = new Error(`Unexpected end of sequence at position ${this.pos + i} [E27: ${JSON.stringify(chr)}]`);
|
|
622
687
|
error.code = 'ParserError27';
|
|
623
688
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
624
689
|
throw error;
|
|
625
690
|
}
|
|
626
|
-
if (this.currentNode.value !== '*' &&
|
|
627
|
-
this.
|
|
628
|
-
this.currentNode.value.at(-2) !== ':') {
|
|
629
|
-
let error = new Error(`Unexpected whitespace at position ${this.pos + i} [E28]`);
|
|
691
|
+
if (this.currentNode.value !== '*' && tokenLast === '*' && tokenPrev !== ':') {
|
|
692
|
+
let error = new Error(`Unexpected end of sequence at position ${this.pos + i} [E28: ${JSON.stringify(chr)}]`);
|
|
630
693
|
error.code = 'ParserError28';
|
|
631
694
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
632
695
|
throw error;
|
|
@@ -635,20 +698,20 @@ export class TokenParser {
|
|
|
635
698
|
this.currentNode.endPos = this.pos + i - 1;
|
|
636
699
|
this.currentNode = this.currentNode.parentNode;
|
|
637
700
|
this.state = STATE_NORMAL;
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
this.currentNode.endPos = this.pos + i;
|
|
645
|
-
this.currentNode = this.currentNode.parentNode;
|
|
646
|
-
this.state = STATE_NORMAL;
|
|
647
|
-
checkSP();
|
|
701
|
+
if (closesParent) {
|
|
702
|
+
this.currentNode.isClosed = true;
|
|
703
|
+
this.currentNode.endPos = this.pos + i;
|
|
704
|
+
this.currentNode = this.currentNode.parentNode;
|
|
705
|
+
checkSP();
|
|
706
|
+
}
|
|
648
707
|
break;
|
|
649
708
|
}
|
|
650
709
|
if (chr === ':') {
|
|
651
|
-
if (!RE_SINGLE_DIGIT.test(
|
|
710
|
+
if (!RE_SINGLE_DIGIT.test(tokenLast) && tokenLast !== '*') {
|
|
711
|
+
if (!tokenHasStar) {
|
|
712
|
+
continueAsAtom();
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
652
715
|
let error = new Error(`Unexpected range separator : at position ${this.pos + i} [E29]`);
|
|
653
716
|
error.code = 'ParserError29';
|
|
654
717
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
@@ -656,7 +719,7 @@ export class TokenParser {
|
|
|
656
719
|
}
|
|
657
720
|
}
|
|
658
721
|
else if (chr === '*') {
|
|
659
|
-
if (![',', ':'].includes(
|
|
722
|
+
if (![',', ':'].includes(tokenLast)) {
|
|
660
723
|
let error = new Error(`Unexpected range wildcard at position ${this.pos + i} [E30]`);
|
|
661
724
|
error.code = 'ParserError30';
|
|
662
725
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
@@ -664,13 +727,17 @@ export class TokenParser {
|
|
|
664
727
|
}
|
|
665
728
|
}
|
|
666
729
|
else if (chr === ',') {
|
|
667
|
-
if (!RE_SINGLE_DIGIT.test(
|
|
730
|
+
if (!RE_SINGLE_DIGIT.test(tokenLast) && tokenLast !== '*') {
|
|
731
|
+
if (!tokenHasStar) {
|
|
732
|
+
continueAsAtom();
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
668
735
|
let error = new Error(`Unexpected sequence separator , at position ${this.pos + i} [E31]`);
|
|
669
736
|
error.code = 'ParserError31';
|
|
670
737
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
671
738
|
throw error;
|
|
672
739
|
}
|
|
673
|
-
if (
|
|
740
|
+
if (tokenLast === '*' && tokenPrev !== ':') {
|
|
674
741
|
let error = new Error(`Unexpected sequence separator , at position ${this.pos + i} [E32]`);
|
|
675
742
|
error.code = 'ParserError32';
|
|
676
743
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
@@ -678,20 +745,29 @@ export class TokenParser {
|
|
|
678
745
|
}
|
|
679
746
|
}
|
|
680
747
|
else if (!RE_SINGLE_DIGIT.test(chr)) {
|
|
748
|
+
if (!tokenHasStar) {
|
|
749
|
+
continueAsAtom();
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
681
752
|
let error = new Error(`Unexpected char at position ${this.pos + i} [E33: ${JSON.stringify(chr)}]`);
|
|
682
753
|
error.code = 'ParserError33';
|
|
683
754
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
684
755
|
throw error;
|
|
685
756
|
}
|
|
686
|
-
if (RE_SINGLE_DIGIT.test(chr) &&
|
|
757
|
+
if (RE_SINGLE_DIGIT.test(chr) && tokenLast === '*') {
|
|
687
758
|
let error = new Error(`Unexpected number at position ${this.pos + i} [E34: ${JSON.stringify(chr)}]`);
|
|
688
759
|
error.code = 'ParserError34';
|
|
689
760
|
error.parserContext = { input: this.str, pos: this.pos + i, chr };
|
|
690
761
|
throw error;
|
|
691
762
|
}
|
|
692
|
-
|
|
763
|
+
appendToToken(chr);
|
|
693
764
|
break;
|
|
765
|
+
}
|
|
694
766
|
}
|
|
695
767
|
}
|
|
768
|
+
// the same applies to a digit-led token that ends the input on a dangling separator ("10:")
|
|
769
|
+
if (this.state === STATE_SEQUENCE && (tokenLast === ':' || tokenLast === ',') && !tokenHasStar) {
|
|
770
|
+
this.currentNode.type = 'ATOM';
|
|
771
|
+
}
|
|
696
772
|
}
|
|
697
773
|
}
|
package/dist/esm/package-info.js
CHANGED
|
@@ -386,8 +386,12 @@ export const searchCompiler = (connection, query) => {
|
|
|
386
386
|
}
|
|
387
387
|
break;
|
|
388
388
|
// NOT operator
|
|
389
|
+
// A falsy operand means no NOT clause (`not: cond && {...}`). Any other one goes
|
|
390
|
+
// through walkOperand(), which refuses a value that is not a query object or
|
|
391
|
+
// compiles to nothing: dropping it would turn the filter into a search that
|
|
392
|
+
// matches everything
|
|
389
393
|
case 'NOT':
|
|
390
|
-
if (params[term]
|
|
394
|
+
if (params[term]) {
|
|
391
395
|
attributes.push({ type: 'ATOM', value: 'NOT' });
|
|
392
396
|
walkOperand('NOT', params[term]);
|
|
393
397
|
}
|
|
@@ -400,8 +404,8 @@ export const searchCompiler = (connection, query) => {
|
|
|
400
404
|
}
|
|
401
405
|
// Single element - just process it directly
|
|
402
406
|
if (params[term].length === 1) {
|
|
403
|
-
if (
|
|
404
|
-
|
|
407
|
+
if (params[term][0]) {
|
|
408
|
+
walkOperand('OR', params[term][0]);
|
|
405
409
|
}
|
|
406
410
|
break;
|
|
407
411
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.6",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cjs/imap-flow.js",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"pretest:workers": "npm run build",
|
|
46
46
|
"test:workers": "node --import tsx --test test/cloudflare/cloudflare-test.ts",
|
|
47
47
|
"test:rev2": "bash test/integration/run-rev2-tests.sh",
|
|
48
|
+
"test:mutation": "node scripts/mutation-test.js",
|
|
48
49
|
"format": "prettier --write \"**/*.{js,cjs,ts,json,md,yml,yaml}\"",
|
|
49
50
|
"format:check": "prettier --check \"**/*.{js,cjs,ts,json,md,yml,yaml}\"",
|
|
50
51
|
"lint": "npm run typecheck && eslint .",
|
|
@@ -77,7 +78,7 @@
|
|
|
77
78
|
"tsx": "4.23.15",
|
|
78
79
|
"types-node-legacy": "npm:@types/node@20.0.0",
|
|
79
80
|
"typescript": "6.0.3",
|
|
80
|
-
"typescript-eslint": "8.71.
|
|
81
|
+
"typescript-eslint": "8.71.1",
|
|
81
82
|
"wrangler": "4.147.0"
|
|
82
83
|
},
|
|
83
84
|
"dependencies": {
|