imapflow 1.4.8 → 1.5.0

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.
Files changed (44) hide show
  1. package/.github/workflows/test.yml +20 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/CLAUDE.md +12 -5
  5. package/Gruntfile.js +3 -1
  6. package/lib/commands/authenticate.js +8 -3
  7. package/lib/commands/enable.js +13 -4
  8. package/lib/commands/expunge.js +2 -2
  9. package/lib/commands/fetch.js +18 -14
  10. package/lib/commands/idle.js +6 -3
  11. package/lib/commands/list.js +241 -61
  12. package/lib/commands/move.js +2 -2
  13. package/lib/commands/namespace.js +3 -1
  14. package/lib/commands/search.js +88 -13
  15. package/lib/commands/status.js +19 -26
  16. package/lib/handler/imap-compiler.js +12 -9
  17. package/lib/handler/token-parser.js +7 -0
  18. package/lib/imap-flow.d.ts +19 -3
  19. package/lib/imap-flow.js +58 -9
  20. package/lib/search-compiler.js +15 -1
  21. package/lib/tools.js +173 -9
  22. package/package.json +3 -2
  23. package/test/commands-branches-test.js +11 -4
  24. package/test/commands-integration-test.js +1528 -108
  25. package/test/connection-edge-cases-test.js +4 -40
  26. package/test/fixtures/test-tls.js +2 -2
  27. package/test/handler-branches-test.js +4 -3
  28. package/test/imap-compiler-test.js +85 -0
  29. package/test/imap-flow-coverage-test.js +8 -1
  30. package/test/imap-flow-fetch-download-test.js +57 -4
  31. package/test/imap-flow-internals-test.js +2 -2
  32. package/test/imap-flow-methods-test.js +65 -6
  33. package/test/imap-flow-secure-test.js +25 -11
  34. package/test/imap-flow-server-test.js +80 -0
  35. package/test/imap-parser-test.js +113 -3
  36. package/test/imap-stream-test.js +46 -0
  37. package/test/integration/README.md +52 -0
  38. package/test/integration/dovecot-test.conf +27 -0
  39. package/test/integration/rev2-live-test.js +367 -0
  40. package/test/integration/run-rev2-tests.sh +75 -0
  41. package/test/reliability-improvements-test.js +4 -1
  42. package/test/search-compiler-test.js +36 -0
  43. package/test/search-test.js +52 -54
  44. package/test/tools-test.js +176 -19
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { decodePath, encodePath, normalizePath } = require('../tools.js');
3
+ const { decodePath, encodePath, normalizePath, enhanceCommandError, hasCapability, isRev2Active, buildStatusQueryAttributes } = require('../tools.js');
4
4
  const { specialUse } = require('../special-use');
5
5
 
6
6
  /**
@@ -29,57 +29,78 @@ module.exports = async (connection, reference, mailbox, options) => {
29
29
 
30
30
  // Prefer XLIST (legacy Gmail extension) only if SPECIAL-USE (RFC 6154) is unavailable.
31
31
  // Both provide special-use flags, but SPECIAL-USE is the standardized approach.
32
- let listCommand = connection.capabilities.has('XLIST') && !connection.capabilities.has('SPECIAL-USE') ? 'XLIST' : 'LIST';
32
+ // SPECIAL-USE is checked with rev2 folding - a rev2 session implies SPECIAL-USE,
33
+ // so LIST is preferred even if a rev2 server also advertised legacy XLIST.
34
+ let listCommand = connection.capabilities.has('XLIST') && !hasCapability(connection, 'SPECIAL-USE') ? 'XLIST' : 'LIST';
33
35
 
34
- let response;
35
36
  try {
36
- let entries = [];
37
-
38
- // statusMap caches STATUS responses received inline via LIST-STATUS extension,
39
- // keyed by normalized mailbox path. This avoids separate STATUS commands per mailbox.
40
- let statusMap = new Map();
41
- let returnArgs = [];
42
- let statusQueryAttributes = [];
43
-
44
- // Build the list of STATUS data items to request (MESSAGES, UIDNEXT, etc.)
45
- if (options.statusQuery) {
46
- Object.keys(options.statusQuery).forEach(key => {
47
- if (!options.statusQuery[key]) {
48
- return;
37
+ // Accumulators filled by the untagged LIST/STATUS handlers below. statusMap
38
+ // caches STATUS responses received inline via LIST-STATUS extension, keyed by
39
+ // normalized mailbox path (avoids separate STATUS commands per mailbox), and
40
+ // specialUseMatches tracks candidate mailboxes for each special-use type.
41
+ // (Re)initialized at the start of each retry stage of the main listing.
42
+ let entries;
43
+ let statusMap;
44
+ let specialUseMatches;
45
+
46
+ // STATUS data items to request (MESSAGES, UIDNEXT, etc.)
47
+ let statusQueryAttributes = buildStatusQueryAttributes(connection, options.statusQuery);
48
+
49
+ // Extended LIST syntax (RETURN options) is understood by servers advertising
50
+ // LIST-EXTENDED (RFC 5258) or IMAP4rev2 (RFC 9051). Deliberately keyed on the
51
+ // advertisement alone (not hasCapability/isRev2Active): the staged retry below
52
+ // handles servers that advertise but reject RETURN options, so the wider gate
53
+ // is safe for anything it covers, while gates without a retry ladder stay
54
+ // conservative.
55
+ let supportsExtendedList = connection.capabilities.has('LIST-EXTENDED') || connection.capabilities.has('IMAP4rev2');
56
+
57
+ // RETURN options for the LIST command. Servers occasionally advertise the
58
+ // extensions but still reject RETURN options - the staged retry below then
59
+ // re-runs the LIST with fewer options and latches a skip flag for the option
60
+ // group the server proved to reject, keeping later listings efficient.
61
+
62
+ // LIST-STATUS (RFC 5819, folded into base IMAP4rev2): request STATUS data
63
+ // inline with LIST, avoiding a separate STATUS command for each mailbox.
64
+ let canRequestStatus =
65
+ listCommand === 'LIST' && !connection.skipListStatusArgs && hasCapability(connection, 'LIST-STATUS') && !!statusQueryAttributes.length;
66
+
67
+ // RETURN (SUBSCRIBED): request subscription state inline instead of a separate
68
+ // LSUB command. IMAP4rev2 removed LSUB entirely, and some servers (e.g.
69
+ // Exchange in IMAP4rev2 mode) reject it with BAD even while still advertising
70
+ // IMAP4rev1.
71
+ let canRequestSubscribed = listCommand === 'LIST' && !options.listOnly && !connection.skipListSubscribedArg && supportsExtendedList;
72
+
73
+ // Auxiliary RETURN options (SPECIAL-USE/CHILDREN) that ride along with the
74
+ // STATUS/SUBSCRIBED option groups. When RETURN options are present, servers
75
+ // may report only what was explicitly requested (verified against Dovecot
76
+ // 2.4: special-use and child attributes disappear from such responses), so
77
+ // request everything a plain LIST would have provided.
78
+ let auxArgsAvailable = hasCapability(connection, 'SPECIAL-USE') || connection.capabilities.has('CHILDREN') || supportsExtendedList;
79
+ let stageHasAuxArgs = stage => (stage.status || stage.subscribed) && stage.aux !== false && !connection.skipListAuxArgs && auxArgsAvailable;
80
+
81
+ // Builds the RETURN (...) argument list for one retry stage
82
+ let buildListArgs = stage => {
83
+ let args = [];
84
+ if (stage.status) {
85
+ args.push({ type: 'ATOM', value: 'STATUS' }, statusQueryAttributes);
86
+ }
87
+ if (stageHasAuxArgs(stage)) {
88
+ if (hasCapability(connection, 'SPECIAL-USE')) {
89
+ args.push({ type: 'ATOM', value: 'SPECIAL-USE' });
49
90
  }
50
-
51
- switch (key.toUpperCase()) {
52
- case 'MESSAGES':
53
- case 'RECENT':
54
- case 'UIDNEXT':
55
- case 'UIDVALIDITY':
56
- case 'UNSEEN':
57
- statusQueryAttributes.push({ type: 'ATOM', value: key.toUpperCase() });
58
- break;
59
-
60
- case 'HIGHESTMODSEQ':
61
- if (connection.capabilities.has('CONDSTORE')) {
62
- statusQueryAttributes.push({ type: 'ATOM', value: key.toUpperCase() });
63
- }
64
- break;
91
+ if (connection.capabilities.has('CHILDREN') || supportsExtendedList) {
92
+ args.push({ type: 'ATOM', value: 'CHILDREN' });
65
93
  }
66
- });
67
- }
68
-
69
- // LIST-STATUS (RFC 5819): allows requesting STATUS data inline with LIST,
70
- // avoiding a separate STATUS command for each mailbox. Adds RETURN (STATUS (...))
71
- // and optionally SPECIAL-USE to the LIST command arguments.
72
- if (listCommand === 'LIST' && connection.capabilities.has('LIST-STATUS') && statusQueryAttributes.length) {
73
- returnArgs.push({ type: 'ATOM', value: 'STATUS' }, statusQueryAttributes);
74
- if (connection.capabilities.has('SPECIAL-USE')) {
75
- returnArgs.push({ type: 'ATOM', value: 'SPECIAL-USE' });
76
94
  }
77
- }
95
+ if (stage.subscribed) {
96
+ args.push({ type: 'ATOM', value: 'SUBSCRIBED' });
97
+ }
98
+ return args;
99
+ };
78
100
 
79
- // Tracks all candidate mailboxes for each special-use type (e.g., \\Sent).
80
- // Multiple mailboxes may claim the same type via different sources (user hint,
81
- // server extension, name match). After listing, the best match wins.
82
- let specialUseMatches = {};
101
+ // Multiple mailboxes may claim the same special-use type (e.g., \\Sent) via
102
+ // different sources (user hint, server extension, name match). After listing,
103
+ // the best match wins.
83
104
  let addSpecialUseMatch = (entry, type, source) => {
84
105
  if (!specialUseMatches[type]) {
85
106
  specialUseMatches[type] = [];
@@ -90,10 +111,17 @@ module.exports = async (connection, reference, mailbox, options) => {
90
111
  // RFC 5258: the \NonExistent attribute implies \Noselect. Some servers only
91
112
  // return \NonExistent for phantom folders, so add \Noselect as well to keep
92
113
  // the flags consistent for consumers that only check \Noselect.
114
+ // RETURN (SUBSCRIBED) - and some LSUB implementations - report subscription
115
+ // state as a \Subscribed attribute. Move it to the subscribed property so the
116
+ // output shape is the same however the state was delivered.
93
117
  let normalizeFlags = entry => {
94
118
  if (entry.flags.has('\\NonExistent')) {
95
119
  entry.flags.add('\\Noselect');
96
120
  }
121
+ if (entry.flags.has('\\Subscribed')) {
122
+ entry.flags.delete('\\Subscribed');
123
+ entry.subscribed = true;
124
+ }
97
125
  };
98
126
 
99
127
  // User-provided hints map mailbox paths to special-use types (e.g., {sent: "Sent Items"}).
@@ -116,14 +144,14 @@ module.exports = async (connection, reference, mailbox, options) => {
116
144
  // Executes a LIST (or XLIST) command and collects mailbox entries.
117
145
  // Called once for the main listing and optionally again for INBOX if a
118
146
  // namespace prefix was used (INBOX may live outside the namespace).
119
- let runList = async (reference, mailbox) => {
147
+ let runList = async (reference, mailbox, returnArgs) => {
120
148
  const cmdArgs = [encodePath(connection, reference), encodePath(connection, mailbox)];
121
149
 
122
150
  if (returnArgs.length) {
123
151
  cmdArgs.push({ type: 'ATOM', value: 'RETURN' }, returnArgs);
124
152
  }
125
153
 
126
- response = await connection.exec(listCommand, cmdArgs, {
154
+ let response = await connection.exec(listCommand, cmdArgs, {
127
155
  untagged: {
128
156
  // Each untagged LIST response: * LIST (<flags>) "<delimiter>" "<mailbox name>"
129
157
  // attributes[0] = flags array, attributes[1] = delimiter, attributes[2] = mailbox name
@@ -159,8 +187,9 @@ module.exports = async (connection, reference, mailbox, options) => {
159
187
  }
160
188
 
161
189
  // Name-based INBOX detection: any mailbox named "INBOX" (case-insensitive)
162
- // is the inbox per RFC 3501.
163
- if (entry.path.toUpperCase() === 'INBOX') {
190
+ // is the inbox per RFC 3501. Phantom \NonExistent entries (subscribed
191
+ // leftovers of deleted mailboxes) must not claim the slot by name.
192
+ if (entry.path.toUpperCase() === 'INBOX' && !entry.flags.has('\\NonExistent')) {
164
193
  addSpecialUseMatch(entry, '\\Inbox', 'name');
165
194
  }
166
195
 
@@ -177,11 +206,14 @@ module.exports = async (connection, reference, mailbox, options) => {
177
206
  // Try to detect special-use from server flags or well-known names
178
207
  // (e.g., "Sent", "Drafts", "Junk", "Trash")
179
208
  let { flag: specialUseFlag, source: flagSource } = specialUse(
180
- connection.capabilities.has('XLIST') || connection.capabilities.has('SPECIAL-USE'),
209
+ connection.capabilities.has('XLIST') || hasCapability(connection, 'SPECIAL-USE'),
181
210
  entry
182
211
  );
183
212
 
184
- if (specialUseFlag) {
213
+ // A name-based guess for a \NonExistent phantom entry could win the
214
+ // special-use slot over the real folder - only server-provided flags
215
+ // are trusted for nonexistent entries
216
+ if (specialUseFlag && (flagSource !== 'name' || !entry.flags.has('\\NonExistent'))) {
185
217
  addSpecialUseMatch(entry, specialUseFlag, flagSource);
186
218
  }
187
219
 
@@ -203,7 +235,10 @@ module.exports = async (connection, reference, mailbox, options) => {
203
235
  UIDNEXT: { key: 'uidNext', parser: Number },
204
236
  UIDVALIDITY: { key: 'uidValidity', parser: BigInt },
205
237
  UNSEEN: { key: 'unseen', parser: Number },
206
- HIGHESTMODSEQ: { key: 'highestModseq', parser: BigInt }
238
+ HIGHESTMODSEQ: { key: 'highestModseq', parser: BigInt },
239
+ // IMAP4rev2 additions (RFC 9051): mailbox size and \Deleted count
240
+ SIZE: { key: 'size', parser: Number },
241
+ DELETED: { key: 'deleted', parser: Number }
207
242
  };
208
243
 
209
244
  let key;
@@ -239,7 +274,87 @@ module.exports = async (connection, reference, mailbox, options) => {
239
274
  };
240
275
 
241
276
  let normalizedReference = normalizePath(connection, reference || '');
242
- await runList(normalizedReference, normalizePath(connection, mailbox || '', true));
277
+ let normalizedMailbox = normalizePath(connection, mailbox || '', true);
278
+
279
+ // Retry stages for the main listing: start with all applicable RETURN options
280
+ // and drop one option group per retry. Consecutive stages differ by exactly one
281
+ // group, so a success right after a rejection identifies the offending group
282
+ // and only that group's skip flag is latched for the rest of the connection.
283
+ // When a stage carrying the auxiliary SPECIAL-USE/CHILDREN options is rejected,
284
+ // a copy of the same stage without them is inserted first (once per listing),
285
+ // so an auxiliary-only rejection does not get a whole option group blamed.
286
+ let stages = [];
287
+ if (canRequestStatus && canRequestSubscribed) {
288
+ stages.push({ status: true, subscribed: true });
289
+ }
290
+ if (canRequestStatus) {
291
+ stages.push({ status: true, subscribed: false });
292
+ } else if (canRequestSubscribed) {
293
+ stages.push({ status: false, subscribed: true });
294
+ }
295
+ stages.push({ status: false, subscribed: false });
296
+
297
+ // A tagged BAD is how servers reject unrecognized RETURN options (RFC 9051
298
+ // section 6.3.9). A tagged NO is an operational failure, and throttling
299
+ // errors (code ETHROTTLE) also surface with a BAD status - neither says
300
+ // anything about the RETURN options, so they propagate to the caller.
301
+ let isRejectedCommand = err => err.responseStatus === 'BAD' && err.code !== 'ETHROTTLE';
302
+
303
+ // Stage of the successful attempt - reused by the INBOX fixup and the LSUB
304
+ // decision below
305
+ let successStage = null;
306
+
307
+ let lastRejectedStage = null;
308
+ let auxRetryInserted = false;
309
+ for (let i = 0; i < stages.length; i++) {
310
+ let stage = stages[i];
311
+ let stageArgs = buildListArgs(stage);
312
+ // Discard partial results from a rejected attempt
313
+ entries = [];
314
+ statusMap = new Map();
315
+ specialUseMatches = {};
316
+ try {
317
+ await runList(normalizedReference, normalizedMailbox, stageArgs);
318
+ if (lastRejectedStage) {
319
+ // Latch only the option group that was present in the rejected
320
+ // attempt but missing from this successful one - that group is
321
+ // proven to be what the server rejects. An unproven group (e.g.
322
+ // SUBSCRIBED when both groups were dropped one by one) is decided
323
+ // by the reduced stage list of the next listing.
324
+ if (lastRejectedStage.subscribed && !stage.subscribed) {
325
+ connection.skipListSubscribedArg = true;
326
+ }
327
+ if (lastRejectedStage.status && !stage.status) {
328
+ connection.skipListStatusArgs = true;
329
+ }
330
+ if (
331
+ stageHasAuxArgs(lastRejectedStage) &&
332
+ stage.aux === false &&
333
+ lastRejectedStage.status === stage.status &&
334
+ lastRejectedStage.subscribed === stage.subscribed
335
+ ) {
336
+ // Same option groups, only the auxiliary args dropped - the
337
+ // auxiliaries are proven to be what the server rejects
338
+ connection.skipListAuxArgs = true;
339
+ }
340
+ }
341
+ successStage = stage;
342
+ break;
343
+ } catch (err) {
344
+ if (i === stages.length - 1 || !isRejectedCommand(err)) {
345
+ throw err;
346
+ }
347
+ lastRejectedStage = stage;
348
+ if (!auxRetryInserted && stageHasAuxArgs(stage)) {
349
+ // The rejection may be about the auxiliary options rather than the
350
+ // option groups - try the same groups without the auxiliaries before
351
+ // dropping a group
352
+ stages.splice(i + 1, 0, { ...stage, aux: false });
353
+ auxRetryInserted = true;
354
+ }
355
+ connection.log.warn({ msg: 'LIST RETURN options rejected, retrying with reduced options', err, cid: connection.id });
356
+ }
357
+ }
243
358
 
244
359
  if (options.listOnly) {
245
360
  return entries;
@@ -248,17 +363,55 @@ module.exports = async (connection, reference, mailbox, options) => {
248
363
  // When listing with a namespace prefix (e.g., "INBOX."), INBOX itself may
249
364
  // not appear in results. Run a separate LIST for INBOX to ensure it's included.
250
365
  if (normalizedReference && !specialUseMatches['\\Inbox']) {
251
- await runList('', 'INBOX');
366
+ let returnArgs = buildListArgs(successStage);
367
+ // Snapshot the accumulator sizes: a rejected fixup attempt may have
368
+ // streamed partial untagged responses before its tagged BAD, and those
369
+ // must be discarded before the retry or INBOX would be listed twice -
370
+ // while the main run's results must be kept
371
+ let entryCountBefore = entries.length;
372
+ let specialUseCountsBefore = {};
373
+ for (let type of Object.keys(specialUseMatches)) {
374
+ specialUseCountsBefore[type] = specialUseMatches[type].length;
375
+ }
376
+ try {
377
+ await runList('', 'INBOX', returnArgs);
378
+ } catch (err) {
379
+ // The main listing just succeeded with the same RETURN options, so a
380
+ // rejection here says nothing about the options themselves - retry
381
+ // this one call plain without latching any skip flags. Accepted edge:
382
+ // if the main run filled statusMap, INBOX ends up without inline
383
+ // status data.
384
+ if (!returnArgs.length || !isRejectedCommand(err)) {
385
+ throw err;
386
+ }
387
+ entries.length = entryCountBefore;
388
+ for (let type of Object.keys(specialUseMatches)) {
389
+ if (!(type in specialUseCountsBefore)) {
390
+ delete specialUseMatches[type];
391
+ } else {
392
+ specialUseMatches[type].length = specialUseCountsBefore[type];
393
+ }
394
+ }
395
+ connection.log.warn({ msg: 'INBOX LIST with RETURN options failed, retrying plain', err, cid: connection.id });
396
+ await runList('', 'INBOX', []);
397
+ }
252
398
  }
253
399
 
254
400
  // Attach STATUS data to each selectable mailbox. If LIST-STATUS was used,
255
401
  // data is already in statusMap; otherwise, fall back to individual STATUS commands.
256
402
  if (options.statusQuery) {
403
+ // RECENT does not exist in IMAP4rev2, so it is never requested from a rev2
404
+ // session - its defined value there is always 0 (the STATUS command module
405
+ // applies the same rule on the per-mailbox fallback path)
406
+ let syntheticRecent = options.statusQuery.recent && isRev2Active(connection);
257
407
  for (let entry of entries) {
258
408
  // \\Noselect and \\NonExistent mailboxes cannot hold messages
259
409
  if (!entry.flags.has('\\Noselect') && !entry.flags.has('\\NonExistent')) {
260
410
  if (statusMap.has(entry.path)) {
261
411
  entry.status = statusMap.get(entry.path);
412
+ if (syntheticRecent) {
413
+ entry.status.recent = 0;
414
+ }
262
415
  } else if (!statusMap.size) {
263
416
  // Server didn't support LIST-STATUS; fall back to per-mailbox STATUS
264
417
  try {
@@ -275,10 +428,8 @@ module.exports = async (connection, reference, mailbox, options) => {
275
428
  // We merge subscription info into the entries already collected from LIST.
276
429
  // Subscribed-only mailboxes that weren't in LIST are intentionally ignored
277
430
  // (they may be phantom entries from old subscriptions to deleted mailboxes).
278
- response = await connection.exec(
279
- 'LSUB',
280
- [encodePath(connection, normalizePath(connection, reference || '')), encodePath(connection, normalizePath(connection, mailbox || '', true))],
281
- {
431
+ let runLsub = async () => {
432
+ let response = await connection.exec('LSUB', [encodePath(connection, normalizedReference), encodePath(connection, normalizedMailbox)], {
282
433
  untagged: {
283
434
  LSUB: async untagged => {
284
435
  if (!untagged.attributes || !untagged.attributes.length) {
@@ -316,9 +467,35 @@ module.exports = async (connection, reference, mailbox, options) => {
316
467
  // Non-listed subscribed folders are intentionally ignored
317
468
  }
318
469
  }
470
+ });
471
+ response.next();
472
+ };
473
+
474
+ // Skipped when RETURN (SUBSCRIBED) already provided subscription state or when
475
+ // this connection's server already rejected LSUB once. Safety net: if the
476
+ // extended LIST was accepted but not a single mailbox came back subscribed on a
477
+ // non-rev2 session, assume the server silently ignored RETURN (SUBSCRIBED) and
478
+ // fall back to LSUB anyway (a rev2 session has no LSUB to fall back to, and an
479
+ // account without any subscriptions legitimately looks the same).
480
+ let needsLsub = !successStage.subscribed || (!isRev2Active(connection) && !entries.some(entry => entry.subscribed));
481
+ if (needsLsub && !connection.skipLsub) {
482
+ try {
483
+ await runLsub();
484
+ } catch (err) {
485
+ if (isRejectedCommand(err)) {
486
+ // Tagged BAD: the server does not recognize the command (IMAP4rev2
487
+ // removed LSUB) - skip LSUB for the rest of this connection
488
+ connection.skipLsub = true;
489
+ } else if (err.responseStatus !== 'NO' || err.code === 'ETHROTTLE') {
490
+ // Transport failures and throttling: rethrow, every follow-up
491
+ // command would fail too or the caller needs to back off
492
+ throw err;
493
+ }
494
+ // Subscription state is auxiliary - keep the LIST results usable. A
495
+ // tagged NO is treated as transient, so the next listing tries again.
496
+ connection.log.warn({ msg: 'Failed to request subscription info', err, cid: connection.id });
319
497
  }
320
- );
321
- response.next();
498
+ }
322
499
 
323
500
  // Resolve special-use conflicts: for each type, pick the best candidate
324
501
  // based on source priority (user > extension > name), then alphabetically.
@@ -372,6 +549,9 @@ module.exports = async (connection, reference, mailbox, options) => {
372
549
  return a.path.localeCompare(b.path);
373
550
  });
374
551
  } catch (err) {
552
+ // Rewrite the parsed err.response into the response text and set
553
+ // serverResponseCode, same as the other command modules
554
+ await enhanceCommandError(err);
375
555
  connection.log.warn({ msg: 'Failed to list folders', err, cid: connection.id });
376
556
  throw err;
377
557
  }
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { normalizePath, encodePath, enhanceCommandError } = require('../tools.js');
3
+ const { normalizePath, encodePath, enhanceCommandError, hasCapability } = require('../tools.js');
4
4
  const { parseCopyUid } = require('./copyuid-parser.js');
5
5
 
6
6
  /**
@@ -31,7 +31,7 @@ module.exports = async (connection, range, destination, options) => {
31
31
 
32
32
  // Fallback for servers without the MOVE extension (RFC 6851):
33
33
  // emulate MOVE using COPY + flag as \Deleted + EXPUNGE.
34
- if (!connection.capabilities.has('MOVE')) {
34
+ if (!hasCapability(connection, 'MOVE')) {
35
35
  let result = await connection.messageCopy(range, destination, options);
36
36
  await connection.messageDelete(range, Object.assign({ silent: true }, options));
37
37
  return result;
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { hasCapability } = require('../tools.js');
4
+
3
5
  /**
4
6
  * Requests NAMESPACE info from the server.
5
7
  *
@@ -12,7 +14,7 @@ module.exports = async connection => {
12
14
  return;
13
15
  }
14
16
 
15
- if (!connection.capabilities.has('NAMESPACE')) {
17
+ if (!hasCapability(connection, 'NAMESPACE')) {
16
18
  // Fallback: when the server does not support the NAMESPACE extension (RFC 2342),
17
19
  // derive the prefix and delimiter from a LIST "" "" command, which returns
18
20
  // the hierarchy delimiter and root name for the default mailbox hierarchy.
@@ -1,8 +1,24 @@
1
1
  'use strict';
2
2
 
3
- const { enhanceCommandError } = require('../tools.js');
3
+ const { enhanceCommandError, hasCapability, isValidSequenceValue } = require('../tools.js');
4
4
  const { searchCompiler } = require('../search-compiler.js');
5
5
 
6
+ /**
7
+ * Strips the leading (TAG "X") correlator list and the optional UID atom from an
8
+ * ESEARCH untagged response, leaving only the result keyword/value pairs.
9
+ * The IMAP parser represents parenthesized groups as plain Arrays, not objects
10
+ * with type: 'LIST'.
11
+ *
12
+ * @param {Array} attrs - Raw attribute array from the IMAP parser
13
+ * @returns {Array} Attribute array starting at the first result keyword
14
+ */
15
+ const stripEsearchPrefix = attrs => {
16
+ let start = 0;
17
+ if (attrs[start] && Array.isArray(attrs[start])) start++;
18
+ if (attrs[start] && typeof attrs[start].value === 'string' && attrs[start].value.toUpperCase() === 'UID') start++;
19
+ return attrs.slice(start);
20
+ };
21
+
6
22
  /**
7
23
  * Parses the key-value attributes from an ESEARCH untagged response.
8
24
  *
@@ -54,9 +70,7 @@ function parseEsearchResponse(attrs) {
54
70
  }
55
71
  case 'PARTIAL': {
56
72
  const listToken = attrs[++i];
57
- // Parser represents parenthesized groups as plain Arrays,
58
- // but check both forms for robustness.
59
- const items = Array.isArray(listToken) ? listToken : listToken && Array.isArray(listToken.attributes) ? listToken.attributes : null;
73
+ const items = Array.isArray(listToken) ? listToken : null;
60
74
  if (!items || items.length < 2) break;
61
75
  result.partial = {
62
76
  range: items[0].value,
@@ -113,7 +127,8 @@ module.exports = async (connection, query, options) => {
113
127
  return false;
114
128
  }
115
129
 
116
- const useEsearch = options.returnOptions && options.returnOptions.length > 0 && connection.capabilities.has('ESEARCH');
130
+ // ESEARCH is part of base IMAP4rev2
131
+ const useEsearch = options.returnOptions && options.returnOptions.length > 0 && hasCapability(connection, 'ESEARCH');
117
132
 
118
133
  if (useEsearch) {
119
134
  // Build RETURN (...) item list
@@ -142,14 +157,7 @@ module.exports = async (connection, query, options) => {
142
157
  untagged: {
143
158
  ESEARCH: async untagged => {
144
159
  if (!untagged || !untagged.attributes) return;
145
- // Strip leading (TAG "X") list and optional UID atom.
146
- // The IMAP parser represents parenthesized groups as
147
- // plain Arrays, not objects with type: 'LIST'.
148
- let attrs = untagged.attributes;
149
- let start = 0;
150
- if (attrs[start] && (Array.isArray(attrs[start]) || attrs[start].type === 'LIST')) start++;
151
- if (attrs[start] && typeof attrs[start].value === 'string' && attrs[start].value.toUpperCase() === 'UID') start++;
152
- esearchResult = parseEsearchResponse(attrs.slice(start));
160
+ esearchResult = parseEsearchResponse(stripEsearchPrefix(untagged.attributes));
153
161
  }
154
162
  }
155
163
  });
@@ -180,6 +188,73 @@ module.exports = async (connection, query, options) => {
180
188
  }
181
189
  });
182
190
  }
191
+ },
192
+
193
+ // IMAP4rev2 servers answer even a plain SEARCH with an untagged
194
+ // ESEARCH response (RFC 9051 deprecated the SEARCH response), so
195
+ // both forms are collected into the same result set
196
+ ESEARCH: async untagged => {
197
+ if (!untagged || !untagged.attributes) {
198
+ return;
199
+ }
200
+ let parsed = parseEsearchResponse(stripEsearchPrefix(untagged.attributes));
201
+ if (parsed.all) {
202
+ // Walk the compact sequence-set directly into the Set - the ALL
203
+ // result may cover the entire mailbox, so expanding it into an
204
+ // intermediate array first would double the peak memory use.
205
+ // The set comes from an untrusted server: endpoints must be
206
+ // valid nz-numbers ('Infinity' would otherwise loop forever)
207
+ // and the expansion stops at the mailbox EXISTS count - a
208
+ // conforming server cannot match more messages than exist, so
209
+ // a hostile range like 1:4294967295 cannot exhaust memory.
210
+ // A '*' means "largest number in use": that is exactly EXISTS
211
+ // for message sequence numbers, while server-sent UID sets may
212
+ // not contain '*' at all (RFC 9051 section 4.1.1), so UID
213
+ // parts with '*' are dropped
214
+ let existsCount = () => (connection.mailbox && connection.mailbox.exists) || 0;
215
+ let overBudget = () => results.size >= existsCount();
216
+ let resolveId = part => (part === '*' ? (options.uid ? 0 : existsCount()) : Number(part));
217
+ let truncated = false;
218
+ let discarded = false;
219
+ sequenceSetLoop: for (let part of parsed.all.split(',')) {
220
+ part = part.trim();
221
+ let colon = part.indexOf(':');
222
+ if (colon < 0) {
223
+ let value = resolveId(part);
224
+ if (!isValidSequenceValue(value)) {
225
+ discarded = true;
226
+ continue;
227
+ }
228
+ if (overBudget()) {
229
+ truncated = true;
230
+ break;
231
+ }
232
+ results.add(value);
233
+ continue;
234
+ }
235
+ let first = resolveId(part.substr(0, colon));
236
+ let second = resolveId(part.substr(colon + 1));
237
+ if (!isValidSequenceValue(first) || !isValidSequenceValue(second)) {
238
+ discarded = true;
239
+ continue;
240
+ }
241
+ for (let id = Math.min(first, second); id <= Math.max(first, second); id++) {
242
+ if (overBudget()) {
243
+ truncated = true;
244
+ break sequenceSetLoop;
245
+ }
246
+ results.add(id);
247
+ }
248
+ }
249
+ if (truncated || discarded) {
250
+ connection.log.warn({
251
+ msg: 'Invalid entries in the ESEARCH ALL result',
252
+ truncated,
253
+ discarded,
254
+ cid: connection.id
255
+ });
256
+ }
257
+ }
183
258
  }
184
259
  }
185
260
  });