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
@@ -49,6 +49,10 @@ const createMockConnection = (overrides = {}) => {
49
49
  close: overrides.close || (() => {}),
50
50
  emit: overrides.emit || (() => {}),
51
51
  currentSelectCommand: false,
52
+ skipListSubscribedArg: false,
53
+ skipListStatusArgs: false,
54
+ skipListAuxArgs: false,
55
+ skipLsub: false,
52
56
  messageFlagsAdd: overrides.messageFlagsAdd || (async () => {}),
53
57
  run: overrides.run || (async () => {}),
54
58
  exec:
@@ -64,6 +68,20 @@ const createMockConnection = (overrides = {}) => {
64
68
  // Decodes the base64 SASL payload that authenticate() hands to exec().
65
69
  const decodeSaslPayload = execArgs => Buffer.from(execArgs.args[1].value, 'base64').toString();
66
70
 
71
+ // Builds the error shape the reader loop attaches to failed commands: tagged
72
+ // rejections carry responseStatus ('BAD'/'NO'), transport and throttling
73
+ // failures carry a code ('NoConnection', 'ETHROTTLE').
74
+ const commandError = (message, responseStatus, code) => {
75
+ let err = new Error(message);
76
+ if (responseStatus) {
77
+ err.responseStatus = responseStatus;
78
+ }
79
+ if (code) {
80
+ err.code = code;
81
+ }
82
+ return err;
83
+ };
84
+
67
85
  // ============================================
68
86
  // CAPABILITY Command Tests
69
87
  // ============================================
@@ -442,6 +460,136 @@ module.exports['Commands: search with ALL'] = async test => {
442
460
  test.done();
443
461
  };
444
462
 
463
+ module.exports['Commands: search collects results from an ESEARCH reply to plain SEARCH'] = async test => {
464
+ const connection = createMockConnection({
465
+ state: 3,
466
+ capabilities: new Map([['IMAP4rev2', true]]),
467
+ exec: async (cmd, attrs, opts) => {
468
+ // IMAP4rev2 servers answer a plain SEARCH with an untagged ESEARCH
469
+ // response instead of the deprecated SEARCH response
470
+ if (opts && opts.untagged && opts.untagged.ESEARCH) {
471
+ await opts.untagged.ESEARCH({
472
+ attributes: [
473
+ [
474
+ { type: 'ATOM', value: 'TAG' },
475
+ { type: 'STRING', value: 'A282' }
476
+ ],
477
+ { type: 'ATOM', value: 'ALL' },
478
+ { type: 'ATOM', value: '1:3,5' }
479
+ ]
480
+ });
481
+ }
482
+ return { next: () => {} };
483
+ }
484
+ });
485
+
486
+ const result = await searchCommand(connection, true, {});
487
+ test.deepEqual(result, [1, 2, 3, 5]);
488
+ test.done();
489
+ };
490
+
491
+ module.exports['Commands: search returns empty array for an ESEARCH reply without ALL'] = async test => {
492
+ const connection = createMockConnection({
493
+ state: 3,
494
+ capabilities: new Map([['IMAP4rev2', true]]),
495
+ exec: async (cmd, attrs, opts) => {
496
+ // RFC 9051: an ESEARCH response with no matches omits the ALL item
497
+ if (opts && opts.untagged && opts.untagged.ESEARCH) {
498
+ await opts.untagged.ESEARCH({
499
+ attributes: [
500
+ [
501
+ { type: 'ATOM', value: 'TAG' },
502
+ { type: 'STRING', value: 'A282' }
503
+ ],
504
+ { type: 'ATOM', value: 'COUNT' },
505
+ { type: 'ATOM', value: '0' }
506
+ ]
507
+ });
508
+ }
509
+ return { next: () => {} };
510
+ }
511
+ });
512
+
513
+ const result = await searchCommand(connection, true, {});
514
+ test.deepEqual(result, []);
515
+ test.done();
516
+ };
517
+
518
+ module.exports['Commands: search caps a hostile ESEARCH ALL range at the mailbox size'] = async test => {
519
+ const connection = createMockConnection({
520
+ state: 3,
521
+ capabilities: new Map([['IMAP4rev2', true]]),
522
+ mailbox: { path: 'INBOX', exists: 100 },
523
+ exec: async (cmd, attrs, opts) => {
524
+ // A few bytes of hostile response must not expand into billions of ids
525
+ await opts.untagged.ESEARCH({
526
+ attributes: [
527
+ { type: 'ATOM', value: 'ALL' },
528
+ { type: 'ATOM', value: '1:4294967295' }
529
+ ]
530
+ });
531
+ return { next: () => {} };
532
+ }
533
+ });
534
+
535
+ const result = await searchCommand(connection, true, {});
536
+ // A conforming server cannot match more messages than the mailbox holds
537
+ test.equal(result.length, 100);
538
+ test.equal(result[0], 1);
539
+ test.equal(result[99], 100);
540
+ test.done();
541
+ };
542
+
543
+ module.exports['Commands: search resolves * in an ESEARCH ALL sequence-set'] = async test => {
544
+ const connection = createMockConnection({
545
+ state: 3,
546
+ capabilities: new Map([['IMAP4rev2', true]]),
547
+ mailbox: { path: 'INBOX', exists: 5 },
548
+ exec: async (cmd, attrs, opts) => {
549
+ await opts.untagged.ESEARCH({
550
+ attributes: [
551
+ { type: 'ATOM', value: 'ALL' },
552
+ { type: 'ATOM', value: '3:*' }
553
+ ]
554
+ });
555
+ return { next: () => {} };
556
+ }
557
+ });
558
+
559
+ const result = await searchCommand(connection, true, {});
560
+ // '*' means the largest sequence number in use, which is the EXISTS count
561
+ test.deepEqual(result, [3, 4, 5]);
562
+ test.done();
563
+ };
564
+
565
+ module.exports['Commands: search drops * from ESEARCH UID results'] = async test => {
566
+ const connection = createMockConnection({
567
+ state: 3,
568
+ capabilities: new Map([['IMAP4rev2', true]]),
569
+ mailbox: { path: 'INBOX', exists: 5, uidNext: 1000 },
570
+ exec: async (cmd, attrs, opts) => {
571
+ await opts.untagged.ESEARCH({
572
+ attributes: [
573
+ [
574
+ { type: 'ATOM', value: 'TAG' },
575
+ { type: 'STRING', value: 'A1' }
576
+ ],
577
+ { type: 'ATOM', value: 'UID' },
578
+ { type: 'ATOM', value: 'ALL' },
579
+ { type: 'ATOM', value: '7,3:*' }
580
+ ]
581
+ });
582
+ return { next: () => {} };
583
+ }
584
+ });
585
+
586
+ const result = await searchCommand(connection, true, { uid: true });
587
+ // Server-sent UID sets may not contain '*' (RFC 9051 4.1.1) - the offending
588
+ // part is dropped, valid parts are kept
589
+ test.deepEqual(result, [7]);
590
+ test.done();
591
+ };
592
+
445
593
  module.exports['Commands: search with UID option'] = async test => {
446
594
  let execCmd = null;
447
595
  const connection = createMockConnection({
@@ -572,6 +720,26 @@ module.exports['Commands: store add flags'] = async test => {
572
720
  test.done();
573
721
  };
574
722
 
723
+ module.exports['Commands: store drops the Recent flag from the wire'] = async test => {
724
+ let execArgs = null;
725
+ const connection = createMockConnection({
726
+ state: 3,
727
+ exec: async (cmd, attrs) => {
728
+ execArgs = { cmd, attrs };
729
+ return { next: () => {} };
730
+ }
731
+ });
732
+
733
+ // \Recent is owned by the server (and removed entirely in IMAP4rev2) - a
734
+ // client-side STORE must never try to set it
735
+ const result = await storeCommand(connection, '1:10', ['\\Seen', '\\Recent'], { operation: 'add' });
736
+ test.equal(result, true);
737
+ const attrsStr = JSON.stringify(execArgs.attrs);
738
+ test.ok(attrsStr.includes('\\\\Seen'));
739
+ test.ok(!attrsStr.toLowerCase().includes('recent'));
740
+ test.done();
741
+ };
742
+
575
743
  module.exports['Commands: store remove flags'] = async test => {
576
744
  let execArgs = null;
577
745
  const connection = createMockConnection({
@@ -1208,6 +1376,23 @@ module.exports['Commands: move with UID and MOVE capability'] = async test => {
1208
1376
  test.done();
1209
1377
  };
1210
1378
 
1379
+ module.exports['Commands: move uses MOVE via folded rev2 capability'] = async test => {
1380
+ let execCmd = null;
1381
+ const connection = createMockConnection({
1382
+ state: 3,
1383
+ // No MOVE token - RFC 9051 folds MOVE into base IMAP4rev2
1384
+ capabilities: new Map([['IMAP4rev2', true]]),
1385
+ exec: async cmd => {
1386
+ execCmd = cmd;
1387
+ return { next: () => {}, response: { attributes: [] } };
1388
+ }
1389
+ });
1390
+
1391
+ await moveCommand(connection, '1:10', 'Archive', {});
1392
+ test.equal(execCmd, 'MOVE');
1393
+ test.done();
1394
+ };
1395
+
1211
1396
  module.exports['Commands: move skips when not selected'] = async test => {
1212
1397
  const connection = createMockConnection({ state: 2 });
1213
1398
 
@@ -1561,6 +1746,25 @@ module.exports['Commands: expunge with UID range'] = async test => {
1561
1746
  test.done();
1562
1747
  };
1563
1748
 
1749
+ module.exports['Commands: expunge uses UID EXPUNGE via folded rev2 capability'] = async test => {
1750
+ let execCmd = null;
1751
+ const connection = createMockConnection({
1752
+ state: 3,
1753
+ // No UIDPLUS token - RFC 9051 folds UIDPLUS into base IMAP4rev2. Falling
1754
+ // back to plain EXPUNGE here would purge every \Deleted message instead
1755
+ // of only the requested range.
1756
+ capabilities: new Map([['IMAP4rev2', true]]),
1757
+ exec: async cmd => {
1758
+ execCmd = cmd;
1759
+ return { next: () => {}, response: { attributes: [] } };
1760
+ }
1761
+ });
1762
+
1763
+ await expungeCommand(connection, '1:100', { uid: true });
1764
+ test.equal(execCmd, 'UID EXPUNGE');
1765
+ test.done();
1766
+ };
1767
+
1564
1768
  module.exports['Commands: expunge skips when not selected'] = async test => {
1565
1769
  const connection = createMockConnection({ state: 2 });
1566
1770
 
@@ -2480,6 +2684,79 @@ module.exports['Commands: enable handles error'] = async test => {
2480
2684
  test.done();
2481
2685
  };
2482
2686
 
2687
+ module.exports['Commands: enable passes IMAP4rev2 through the capability prefilter'] = async test => {
2688
+ let enableAttrs = null;
2689
+ const connection = createMockConnection({
2690
+ state: 2,
2691
+ capabilities: new Map([
2692
+ ['ENABLE', true],
2693
+ ['IMAP4rev1', true],
2694
+ // Canonical mixed-case key as stored by updateCapabilities
2695
+ ['IMAP4rev2', true]
2696
+ ]),
2697
+ exec: async (cmd, attrs, opts) => {
2698
+ enableAttrs = attrs;
2699
+ if (opts && opts.untagged && opts.untagged.ENABLED) {
2700
+ await opts.untagged.ENABLED({ attributes: [{ value: 'IMAP4rev2' }] });
2701
+ }
2702
+ return { next: () => {} };
2703
+ }
2704
+ });
2705
+
2706
+ const result = await enableCommand(connection, ['IMAP4rev2']);
2707
+ // The mixed-case capability key must not trip the case-sensitive lookup
2708
+ test.ok(enableAttrs);
2709
+ test.ok(enableAttrs.some(attr => attr.value === 'IMAP4REV2'));
2710
+ test.ok(result.has('IMAP4REV2'));
2711
+ test.done();
2712
+ };
2713
+
2714
+ module.exports['Commands: enable merges into previously enabled extensions'] = async test => {
2715
+ const connection = createMockConnection({
2716
+ state: 2,
2717
+ capabilities: new Map([
2718
+ ['ENABLE', true],
2719
+ ['IMAP4rev1', true],
2720
+ ['IMAP4rev2', true]
2721
+ ]),
2722
+ enabled: new Set(['CONDSTORE']),
2723
+ exec: async (cmd, attrs, opts) => {
2724
+ if (opts && opts.untagged && opts.untagged.ENABLED) {
2725
+ await opts.untagged.ENABLED({ attributes: [{ value: 'IMAP4rev2' }] });
2726
+ }
2727
+ return { next: () => {} };
2728
+ }
2729
+ });
2730
+
2731
+ await enableCommand(connection, ['IMAP4rev2']);
2732
+ // The ENABLED response only lists newly enabled extensions - earlier grants
2733
+ // must survive
2734
+ test.ok(connection.enabled.has('CONDSTORE'));
2735
+ test.ok(connection.enabled.has('IMAP4REV2'));
2736
+ test.done();
2737
+ };
2738
+
2739
+ module.exports['Commands: enable works without the ENABLE token on rev2-only servers'] = async test => {
2740
+ let execCalled = false;
2741
+ const connection = createMockConnection({
2742
+ state: 2,
2743
+ // ENABLE is part of base IMAP4rev2 - rev2-only servers may omit the token
2744
+ capabilities: new Map([['IMAP4rev2', true]]),
2745
+ exec: async (cmd, attrs, opts) => {
2746
+ execCalled = true;
2747
+ if (opts && opts.untagged && opts.untagged.ENABLED) {
2748
+ await opts.untagged.ENABLED({ attributes: [{ value: 'IMAP4rev2' }] });
2749
+ }
2750
+ return { next: () => {} };
2751
+ }
2752
+ });
2753
+
2754
+ const result = await enableCommand(connection, ['IMAP4rev2']);
2755
+ test.equal(execCalled, true);
2756
+ test.ok(result.has('IMAP4REV2'));
2757
+ test.done();
2758
+ };
2759
+
2483
2760
  // ============================================
2484
2761
  // COMPRESS Command Tests
2485
2762
  // ============================================
@@ -2745,6 +3022,97 @@ module.exports['Commands: fetch with BINARY capability'] = async test => {
2745
3022
  test.done();
2746
3023
  };
2747
3024
 
3025
+ module.exports['Commands: fetch with binary uses BINARY on rev2-only servers without the token'] = async test => {
3026
+ let queryAttrs = null;
3027
+ const connection = createMockConnection({
3028
+ state: 3,
3029
+ // rev2-only server: no BINARY token, but RFC 9051 folds the FETCH side of
3030
+ // the BINARY extension into base IMAP4rev2
3031
+ capabilities: new Map([['IMAP4rev2', true]]),
3032
+ exec: async (cmd, attrs) => {
3033
+ queryAttrs = attrs;
3034
+ return { next: () => {} };
3035
+ }
3036
+ });
3037
+
3038
+ await fetchCommand(connection, '1', { source: true }, { binary: true });
3039
+ test.ok(queryAttrs);
3040
+ test.ok(JSON.stringify(queryAttrs).includes('BINARY.PEEK'));
3041
+ test.done();
3042
+ };
3043
+
3044
+ module.exports['Commands: fetch with binary keeps BODY for non-numeric sections'] = async test => {
3045
+ let queryAttrs = null;
3046
+ const connection = createMockConnection({
3047
+ state: 3,
3048
+ capabilities: new Map([['BINARY', true]]),
3049
+ exec: async (cmd, attrs) => {
3050
+ queryAttrs = attrs;
3051
+ return { next: () => {} };
3052
+ }
3053
+ });
3054
+
3055
+ // RFC 3516/RFC 9051: section-binary only allows numeric part specifiers -
3056
+ // BINARY[HEADER], BINARY[TEXT] and BINARY[n.MIME] are invalid syntax that
3057
+ // servers reject, so those sections must stay BODY fetches even with
3058
+ // options.binary set
3059
+ await fetchCommand(connection, '1', { headers: true, bodyParts: ['TEXT', '1.MIME', '1.2'] }, { binary: true });
3060
+ test.ok(queryAttrs);
3061
+ const sections = [];
3062
+ const walk = list => {
3063
+ for (let entry of Array.isArray(list) ? list : [list]) {
3064
+ if (Array.isArray(entry)) {
3065
+ walk(entry);
3066
+ } else if (entry && entry.section) {
3067
+ sections.push({ value: entry.value, section: entry.section.length ? entry.section[0].value : '' });
3068
+ }
3069
+ }
3070
+ };
3071
+ walk(queryAttrs);
3072
+
3073
+ for (let entry of sections) {
3074
+ if (['HEADER', 'TEXT', '1.MIME'].includes(entry.section)) {
3075
+ test.equal(entry.value, 'BODY.PEEK', `${entry.section} must be fetched via BODY.PEEK`);
3076
+ }
3077
+ if (entry.section === '1.2') {
3078
+ test.equal(entry.value, 'BINARY.PEEK', 'numeric part specifiers may use BINARY.PEEK');
3079
+ }
3080
+ }
3081
+ test.ok(
3082
+ sections.some(entry => entry.section === '1.2'),
3083
+ 'numeric body part present'
3084
+ );
3085
+ test.ok(
3086
+ sections.some(entry => entry.section === 'TEXT'),
3087
+ 'TEXT body part present'
3088
+ );
3089
+ test.done();
3090
+ };
3091
+
3092
+ module.exports['Commands: fetch with binary keeps BODY on unenabled dual rev1+rev2 servers'] = async test => {
3093
+ let queryAttrs = null;
3094
+ const connection = createMockConnection({
3095
+ state: 3,
3096
+ // dual server without ENABLE IMAP4rev2 - rev2 semantics are not active, so
3097
+ // the BINARY fold must not apply
3098
+ capabilities: new Map([
3099
+ ['IMAP4rev1', true],
3100
+ ['IMAP4rev2', true]
3101
+ ]),
3102
+ exec: async (cmd, attrs) => {
3103
+ queryAttrs = attrs;
3104
+ return { next: () => {} };
3105
+ }
3106
+ });
3107
+
3108
+ await fetchCommand(connection, '1', { source: true }, { binary: true });
3109
+ test.ok(queryAttrs);
3110
+ const queryStr = JSON.stringify(queryAttrs);
3111
+ test.ok(queryStr.includes('BODY.PEEK'));
3112
+ test.ok(!queryStr.includes('BINARY.PEEK'));
3113
+ test.done();
3114
+ };
3115
+
2748
3116
  module.exports['Commands: fetch with OBJECTID capability'] = async test => {
2749
3117
  let queryAttrs = null;
2750
3118
  const connection = createMockConnection({
@@ -3223,14 +3591,11 @@ module.exports['Commands: list with statusQuery'] = async test => {
3223
3591
  test.done();
3224
3592
  };
3225
3593
 
3226
- module.exports['Commands: list with CONDSTORE status query'] = async test => {
3594
+ module.exports['Commands: list statusQuery parses inline SIZE and DELETED on rev2 sessions'] = async test => {
3227
3595
  let listAttrs = null;
3228
3596
  const connection = createMockConnection({
3229
3597
  state: 3,
3230
- capabilities: new Map([
3231
- ['LIST-STATUS', true],
3232
- ['CONDSTORE', true]
3233
- ]),
3598
+ capabilities: new Map([['IMAP4rev2', true]]),
3234
3599
  exec: async (cmd, attrs, opts) => {
3235
3600
  if (cmd === 'LIST') {
3236
3601
  listAttrs = attrs;
@@ -3239,49 +3604,117 @@ module.exports['Commands: list with CONDSTORE status query'] = async test => {
3239
3604
  attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3240
3605
  });
3241
3606
  }
3607
+ if (opts && opts.untagged && opts.untagged.STATUS) {
3608
+ await opts.untagged.STATUS({
3609
+ attributes: [
3610
+ { value: 'INBOX' },
3611
+ [{ value: 'MESSAGES' }, { value: '10' }, { value: 'SIZE' }, { value: '12345678901234' }, { value: 'DELETED' }, { value: '3' }]
3612
+ ]
3613
+ });
3614
+ }
3242
3615
  }
3243
3616
  return { next: () => {} };
3244
3617
  }
3245
3618
  });
3246
3619
 
3247
- await listCommand(connection, '', '*', {
3248
- statusQuery: { highestModseq: true }
3620
+ const result = await listCommand(connection, '', '*', {
3621
+ statusQuery: { messages: true, size: true, deleted: true }
3249
3622
  });
3250
- test.ok(listAttrs);
3251
3623
  const attrsStr = JSON.stringify(listAttrs);
3252
- test.ok(attrsStr.includes('HIGHESTMODSEQ'));
3624
+ test.ok(attrsStr.includes('SIZE'));
3625
+ test.ok(attrsStr.includes('DELETED'));
3626
+ const inbox = result.find(entry => entry.path === 'INBOX');
3627
+ test.ok(inbox);
3628
+ test.equal(inbox.status.messages, 10);
3629
+ // STATUS SIZE is a number64 - values beyond 2^32 must survive
3630
+ test.strictEqual(inbox.status.size, 12345678901234);
3631
+ test.strictEqual(inbox.status.deleted, 3);
3253
3632
  test.done();
3254
3633
  };
3255
3634
 
3256
- module.exports['Commands: list with listOnly option'] = async test => {
3257
- let lsubCalled = false;
3635
+ module.exports['Commands: list tolerates an OLDNAME extended data item'] = async test => {
3258
3636
  const connection = createMockConnection({
3259
3637
  state: 3,
3638
+ capabilities: new Map([['IMAP4rev2', true]]),
3260
3639
  exec: async (cmd, attrs, opts) => {
3261
- if (cmd === 'LSUB') {
3262
- lsubCalled = true;
3263
- }
3264
3640
  if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3641
+ // RFC 9051 6.3.9.7: a LIST response may carry an OLDNAME extended
3642
+ // data item after a RENAME or name normalization - the client must
3643
+ // parse the response without choking on the extra attribute
3265
3644
  await opts.untagged.LIST({
3266
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3645
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'NewBox' }, [{ value: 'OLDNAME' }, [{ value: 'OldBox' }]]]
3267
3646
  });
3268
3647
  }
3269
3648
  return { next: () => {} };
3270
3649
  }
3271
3650
  });
3272
3651
 
3273
- const result = await listCommand(connection, '', '*', { listOnly: true });
3274
- test.equal(lsubCalled, false);
3275
- test.ok(Array.isArray(result));
3652
+ const result = await listCommand(connection, '', '*');
3653
+ const entry = result.find(folder => folder.path === 'NewBox');
3654
+ test.ok(entry, 'mailbox with OLDNAME extended data must be listed');
3655
+ test.ok(entry.flags.has('\\HasNoChildren'));
3276
3656
  test.done();
3277
3657
  };
3278
3658
 
3279
- module.exports['Commands: list with specialUseHints'] = async test => {
3659
+ module.exports['Commands: list with CONDSTORE status query'] = async test => {
3660
+ let listAttrs = null;
3280
3661
  const connection = createMockConnection({
3281
3662
  state: 3,
3282
- exec: async (cmd, attrs, opts) => {
3283
- if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3284
- await opts.untagged.LIST({
3663
+ capabilities: new Map([
3664
+ ['LIST-STATUS', true],
3665
+ ['CONDSTORE', true]
3666
+ ]),
3667
+ exec: async (cmd, attrs, opts) => {
3668
+ if (cmd === 'LIST') {
3669
+ listAttrs = attrs;
3670
+ if (opts && opts.untagged && opts.untagged.LIST) {
3671
+ await opts.untagged.LIST({
3672
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3673
+ });
3674
+ }
3675
+ }
3676
+ return { next: () => {} };
3677
+ }
3678
+ });
3679
+
3680
+ await listCommand(connection, '', '*', {
3681
+ statusQuery: { highestModseq: true }
3682
+ });
3683
+ test.ok(listAttrs);
3684
+ const attrsStr = JSON.stringify(listAttrs);
3685
+ test.ok(attrsStr.includes('HIGHESTMODSEQ'));
3686
+ test.done();
3687
+ };
3688
+
3689
+ module.exports['Commands: list with listOnly option'] = async test => {
3690
+ let lsubCalled = false;
3691
+ const connection = createMockConnection({
3692
+ state: 3,
3693
+ exec: async (cmd, attrs, opts) => {
3694
+ if (cmd === 'LSUB') {
3695
+ lsubCalled = true;
3696
+ }
3697
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3698
+ await opts.untagged.LIST({
3699
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3700
+ });
3701
+ }
3702
+ return { next: () => {} };
3703
+ }
3704
+ });
3705
+
3706
+ const result = await listCommand(connection, '', '*', { listOnly: true });
3707
+ test.equal(lsubCalled, false);
3708
+ test.ok(Array.isArray(result));
3709
+ test.done();
3710
+ };
3711
+
3712
+ module.exports['Commands: list with specialUseHints'] = async test => {
3713
+ const connection = createMockConnection({
3714
+ state: 3,
3715
+ exec: async (cmd, attrs, opts) => {
3716
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3717
+ await opts.untagged.LIST({
3285
3718
  attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Sent Items' }]
3286
3719
  });
3287
3720
  }
@@ -3447,51 +3880,839 @@ module.exports['Commands: list status fallback when LIST-STATUS not supported']
3447
3880
  test.done();
3448
3881
  };
3449
3882
 
3450
- module.exports['Commands: list handles STATUS errors gracefully'] = async test => {
3883
+ module.exports['Commands: list handles STATUS errors gracefully'] = async test => {
3884
+ const connection = createMockConnection({
3885
+ state: 3,
3886
+ capabilities: new Map(),
3887
+ run: async cmd => {
3888
+ if (cmd === 'STATUS') {
3889
+ throw new Error('Status failed');
3890
+ }
3891
+ },
3892
+ exec: async (cmd, attrs, opts) => {
3893
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3894
+ await opts.untagged.LIST({
3895
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3896
+ });
3897
+ }
3898
+ return { next: () => {} };
3899
+ }
3900
+ });
3901
+
3902
+ const result = await listCommand(connection, '', '*', {
3903
+ statusQuery: { messages: true }
3904
+ });
3905
+ const inbox = result.find(e => e.path === 'INBOX');
3906
+ test.ok(inbox);
3907
+ // Status should have error property
3908
+ test.ok(inbox.status);
3909
+ test.ok(inbox.status.error);
3910
+ test.done();
3911
+ };
3912
+
3913
+ module.exports['Commands: list sorts by special use'] = async test => {
3914
+ const connection = createMockConnection({
3915
+ state: 3,
3916
+ capabilities: new Map([['SPECIAL-USE', true]]),
3917
+ exec: async (cmd, attrs, opts) => {
3918
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3919
+ // Add folders out of order
3920
+ await opts.untagged.LIST({
3921
+ attributes: [[{ value: '\\Trash' }], { value: '/' }, { value: 'Trash' }]
3922
+ });
3923
+ await opts.untagged.LIST({
3924
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3925
+ });
3926
+ await opts.untagged.LIST({
3927
+ attributes: [[{ value: '\\Sent' }], { value: '/' }, { value: 'Sent' }]
3928
+ });
3929
+ }
3930
+ return { next: () => {} };
3931
+ }
3932
+ });
3933
+
3934
+ const result = await listCommand(connection, '', '*');
3935
+ // INBOX should be first (has \\Inbox special use)
3936
+ test.equal(result[0].specialUse, '\\Inbox');
3937
+ test.done();
3938
+ };
3939
+
3940
+ module.exports['Commands: list handles delimiter in path'] = async test => {
3941
+ const connection = createMockConnection({
3942
+ state: 3,
3943
+ exec: async (cmd, attrs, opts) => {
3944
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3945
+ await opts.untagged.LIST({
3946
+ attributes: [
3947
+ [{ value: '\\HasNoChildren' }],
3948
+ { value: '/' },
3949
+ { value: '/Leading/Slash' } // Path starts with delimiter
3950
+ ]
3951
+ });
3952
+ }
3953
+ return { next: () => {} };
3954
+ }
3955
+ });
3956
+
3957
+ const result = await listCommand(connection, '', '*');
3958
+ const folder = result.find(e => e.name === 'Slash');
3959
+ test.ok(folder);
3960
+ // Leading delimiter should be removed
3961
+ test.equal(folder.path, 'Leading/Slash');
3962
+ test.done();
3963
+ };
3964
+
3965
+ module.exports['Commands: list skips Noselect folders for status'] = async test => {
3966
+ let statusCalls = 0;
3967
+ const connection = createMockConnection({
3968
+ state: 3,
3969
+ capabilities: new Map(),
3970
+ run: async cmd => {
3971
+ if (cmd === 'STATUS') {
3972
+ statusCalls++;
3973
+ return { messages: 10 };
3974
+ }
3975
+ },
3976
+ exec: async (cmd, attrs, opts) => {
3977
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3978
+ await opts.untagged.LIST({
3979
+ attributes: [[{ value: '\\Noselect' }], { value: '/' }, { value: 'Parent' }]
3980
+ });
3981
+ }
3982
+ return { next: () => {} };
3983
+ }
3984
+ });
3985
+
3986
+ await listCommand(connection, '', '*', { statusQuery: { messages: true } });
3987
+ // STATUS should not be called for Noselect folders
3988
+ test.equal(statusCalls, 0);
3989
+ test.done();
3990
+ };
3991
+
3992
+ module.exports['Commands: list adds Noselect to NonExistent mailboxes'] = async test => {
3993
+ const connection = createMockConnection({
3994
+ state: 3,
3995
+ exec: async (cmd, attrs, opts) => {
3996
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3997
+ await opts.untagged.LIST({
3998
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3999
+ });
4000
+ await opts.untagged.LIST({
4001
+ attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Phantom' }]
4002
+ });
4003
+ }
4004
+ return { next: () => {} };
4005
+ }
4006
+ });
4007
+
4008
+ const result = await listCommand(connection, '', '*');
4009
+ const phantom = result.find(e => e.path === 'Phantom');
4010
+ test.ok(phantom);
4011
+ // RFC 5258: \\NonExistent implies \\Noselect
4012
+ test.equal(phantom.flags.has('\\Noselect'), true);
4013
+ // The original flag is preserved, not replaced
4014
+ test.equal(phantom.flags.has('\\NonExistent'), true);
4015
+ const inbox = result.find(e => e.path === 'INBOX');
4016
+ test.ok(inbox);
4017
+ test.equal(inbox.flags.has('\\Noselect'), false);
4018
+ test.done();
4019
+ };
4020
+
4021
+ module.exports['Commands: list LSUB merge adds Noselect to NonExistent'] = async test => {
4022
+ const connection = createMockConnection({
4023
+ state: 3,
4024
+ exec: async (cmd, attrs, opts) => {
4025
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4026
+ await opts.untagged.LIST({
4027
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4028
+ });
4029
+ }
4030
+ if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
4031
+ // Some servers only report \\NonExistent in LSUB responses
4032
+ await opts.untagged.LSUB({
4033
+ attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Folder1' }]
4034
+ });
4035
+ }
4036
+ return { next: () => {} };
4037
+ }
4038
+ });
4039
+
4040
+ const result = await listCommand(connection, '', '*');
4041
+ const folder = result.find(e => e.path === 'Folder1');
4042
+ test.ok(folder);
4043
+ test.equal(folder.subscribed, true);
4044
+ test.equal(folder.flags.has('\\NonExistent'), true);
4045
+ // RFC 5258: \\NonExistent merged from LSUB implies \\Noselect
4046
+ test.equal(folder.flags.has('\\Noselect'), true);
4047
+ test.done();
4048
+ };
4049
+
4050
+ module.exports['Commands: list uses RETURN (SUBSCRIBED) instead of LSUB on IMAP4rev2'] = async test => {
4051
+ let lsubCalled = false;
4052
+ let listAttrs = false;
4053
+ const connection = createMockConnection({
4054
+ state: 3,
4055
+ capabilities: new Map([['IMAP4rev2', true]]),
4056
+ exec: async (cmd, attrs, opts) => {
4057
+ if (cmd === 'LSUB') {
4058
+ lsubCalled = true;
4059
+ }
4060
+ if (cmd === 'LIST') {
4061
+ listAttrs = attrs;
4062
+ if (opts && opts.untagged && opts.untagged.LIST) {
4063
+ await opts.untagged.LIST({
4064
+ attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4065
+ });
4066
+ await opts.untagged.LIST({
4067
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder2' }]
4068
+ });
4069
+ }
4070
+ }
4071
+ return { next: () => {} };
4072
+ }
4073
+ });
4074
+
4075
+ const result = await listCommand(connection, '', '*');
4076
+ // IMAP4rev2 removed LSUB, subscription state comes from RETURN (SUBSCRIBED)
4077
+ test.equal(lsubCalled, false);
4078
+ test.ok(JSON.stringify(listAttrs).includes('SUBSCRIBED'));
4079
+ const folder1 = result.find(e => e.path === 'Folder1');
4080
+ test.ok(folder1);
4081
+ test.equal(folder1.subscribed, true);
4082
+ // The \Subscribed attribute is folded into the subscribed property
4083
+ test.equal(folder1.flags.has('\\Subscribed'), false);
4084
+ const folder2 = result.find(e => e.path === 'Folder2');
4085
+ test.ok(folder2);
4086
+ test.ok(!folder2.subscribed);
4087
+ test.done();
4088
+ };
4089
+
4090
+ module.exports['Commands: list uses RETURN (SUBSCRIBED) with LIST-EXTENDED'] = async test => {
4091
+ let lsubCalled = false;
4092
+ let listAttrs = false;
4093
+ const connection = createMockConnection({
4094
+ state: 3,
4095
+ capabilities: new Map([
4096
+ ['IMAP4rev1', true],
4097
+ ['LIST-EXTENDED', true]
4098
+ ]),
4099
+ exec: async (cmd, attrs, opts) => {
4100
+ if (cmd === 'LSUB') {
4101
+ lsubCalled = true;
4102
+ }
4103
+ if (cmd === 'LIST') {
4104
+ listAttrs = attrs;
4105
+ if (opts && opts.untagged && opts.untagged.LIST) {
4106
+ await opts.untagged.LIST({
4107
+ attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4108
+ });
4109
+ }
4110
+ }
4111
+ return { next: () => {} };
4112
+ }
4113
+ });
4114
+
4115
+ const result = await listCommand(connection, '', '*');
4116
+ test.equal(lsubCalled, false);
4117
+ test.ok(JSON.stringify(listAttrs).includes('SUBSCRIBED'));
4118
+ const inbox = result.find(e => e.path === 'INBOX');
4119
+ test.ok(inbox);
4120
+ test.equal(inbox.subscribed, true);
4121
+ test.done();
4122
+ };
4123
+
4124
+ module.exports['Commands: list listOnly does not add RETURN args on IMAP4rev2'] = async test => {
4125
+ let listAttrs = false;
4126
+ const connection = createMockConnection({
4127
+ state: 3,
4128
+ capabilities: new Map([['IMAP4rev2', true]]),
4129
+ exec: async (cmd, attrs, opts) => {
4130
+ if (cmd === 'LIST') {
4131
+ listAttrs = attrs;
4132
+ if (opts && opts.untagged && opts.untagged.LIST) {
4133
+ await opts.untagged.LIST({
4134
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4135
+ });
4136
+ }
4137
+ }
4138
+ return { next: () => {} };
4139
+ }
4140
+ });
4141
+
4142
+ await listCommand(connection, '', '*', { listOnly: true });
4143
+ // Just reference and pattern, no RETURN block
4144
+ test.equal(listAttrs.length, 2);
4145
+ test.done();
4146
+ };
4147
+
4148
+ module.exports['Commands: list survives LSUB rejection'] = async test => {
4149
+ let lsubCalls = 0;
4150
+ const connection = createMockConnection({
4151
+ state: 3,
4152
+ exec: async (cmd, attrs, opts) => {
4153
+ if (cmd === 'LSUB') {
4154
+ lsubCalls++;
4155
+ // e.g. Exchange in IMAP4rev2 mode responds "BAD Command Argument Error"
4156
+ throw commandError('Command failed', 'BAD');
4157
+ }
4158
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4159
+ await opts.untagged.LIST({
4160
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4161
+ });
4162
+ await opts.untagged.LIST({
4163
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4164
+ });
4165
+ }
4166
+ return { next: () => {} };
4167
+ }
4168
+ });
4169
+
4170
+ const result = await listCommand(connection, '', '*');
4171
+ test.equal(result.length, 2);
4172
+ const inbox = result.find(e => e.path === 'INBOX');
4173
+ test.ok(inbox);
4174
+ // INBOX is always reported as subscribed even without LSUB data
4175
+ test.equal(inbox.subscribed, true);
4176
+
4177
+ // The rejection is remembered - a follow-up listing skips LSUB entirely
4178
+ test.equal(connection.skipLsub, true);
4179
+ await listCommand(connection, '', '*');
4180
+ test.equal(lsubCalls, 1);
4181
+ test.done();
4182
+ };
4183
+
4184
+ module.exports['Commands: list fails when LSUB dies without a server rejection'] = async test => {
4185
+ const connection = createMockConnection({
4186
+ state: 3,
4187
+ exec: async (cmd, attrs, opts) => {
4188
+ if (cmd === 'LSUB') {
4189
+ // Transport failure, no tagged BAD/NO from the server
4190
+ throw commandError('Connection not available', null, 'NoConnection');
4191
+ }
4192
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4193
+ await opts.untagged.LIST({
4194
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4195
+ });
4196
+ }
4197
+ return { next: () => {} };
4198
+ }
4199
+ });
4200
+
4201
+ try {
4202
+ await listCommand(connection, '', '*');
4203
+ test.ok(false, 'Should have thrown');
4204
+ } catch (err) {
4205
+ test.equal(err.code, 'NoConnection');
4206
+ }
4207
+ // A transport failure says nothing about LSUB support - must not latch
4208
+ test.ok(!connection.skipLsub);
4209
+ test.done();
4210
+ };
4211
+
4212
+ module.exports['Commands: list rewrites a parsed error response into text'] = async test => {
4213
+ const connection = createMockConnection({
4214
+ state: 3,
4215
+ exec: async () => {
4216
+ // Same shape as the reader loop attaches for a tagged BAD
4217
+ let err = commandError('Command failed', 'BAD');
4218
+ err.response = { tag: '5', command: 'BAD', attributes: [{ type: 'TEXT', value: 'Command Argument Error. 12' }] };
4219
+ throw err;
4220
+ }
4221
+ });
4222
+
4223
+ try {
4224
+ await listCommand(connection, '', '*');
4225
+ test.ok(false, 'Should have thrown');
4226
+ } catch (err) {
4227
+ // enhanceCommandError folds the parsed object into a plain string
4228
+ test.equal(typeof err.response, 'string');
4229
+ test.ok(err.response.includes('Command Argument Error'));
4230
+ }
4231
+ test.done();
4232
+ };
4233
+
4234
+ module.exports['Commands: list retries with plain LIST when RETURN is rejected'] = async test => {
4235
+ let listCalls = 0;
4236
+ let lsubCalled = false;
4237
+ const connection = createMockConnection({
4238
+ state: 3,
4239
+ capabilities: new Map([
4240
+ ['IMAP4rev1', true],
4241
+ ['LIST-EXTENDED', true]
4242
+ ]),
4243
+ exec: async (cmd, attrs, opts) => {
4244
+ if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
4245
+ lsubCalled = true;
4246
+ await opts.untagged.LSUB({
4247
+ attributes: [[], { value: '/' }, { value: 'Folder1' }]
4248
+ });
4249
+ }
4250
+ if (cmd === 'LIST') {
4251
+ listCalls++;
4252
+ if (JSON.stringify(attrs).includes('SUBSCRIBED')) {
4253
+ // Partial untagged response arrives before the tagged BAD
4254
+ if (opts && opts.untagged && opts.untagged.LIST) {
4255
+ await opts.untagged.LIST({
4256
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4257
+ });
4258
+ }
4259
+ throw commandError('Command failed', 'BAD');
4260
+ }
4261
+ // Retry must be a plain LIST without RETURN args
4262
+ test.equal(attrs.length, 2);
4263
+ if (opts && opts.untagged && opts.untagged.LIST) {
4264
+ await opts.untagged.LIST({
4265
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4266
+ });
4267
+ }
4268
+ }
4269
+ return { next: () => {} };
4270
+ }
4271
+ });
4272
+
4273
+ const result = await listCommand(connection, '', '*');
4274
+ // Extended attempt, auxiliary-free attempt, plain attempt
4275
+ test.equal(listCalls, 3);
4276
+ test.equal(lsubCalled, true);
4277
+ // Partial rejected-attempt results were discarded - no duplicate entries
4278
+ test.equal(result.filter(e => e.path === 'Folder1').length, 1);
4279
+ const folder = result.find(e => e.path === 'Folder1');
4280
+ test.equal(folder.subscribed, true);
4281
+
4282
+ // The plain retry succeeding right after the rejection proves SUBSCRIBED was
4283
+ // the offending option - a follow-up listing goes straight to plain LIST
4284
+ test.equal(connection.skipListSubscribedArg, true);
4285
+ test.ok(!connection.skipListStatusArgs);
4286
+ await listCommand(connection, '', '*');
4287
+ test.equal(listCalls, 4);
4288
+ test.done();
4289
+ };
4290
+
4291
+ module.exports['Commands: list does not retry extended LIST on transport errors'] = async test => {
4292
+ let listCalls = 0;
4293
+ const connection = createMockConnection({
4294
+ state: 3,
4295
+ capabilities: new Map([['IMAP4rev2', true]]),
4296
+ exec: async cmd => {
4297
+ if (cmd === 'LIST') {
4298
+ listCalls++;
4299
+ // Dropped connection - no tagged BAD/NO from the server
4300
+ throw commandError('Connection not available', null, 'NoConnection');
4301
+ }
4302
+ return { next: () => {} };
4303
+ }
4304
+ });
4305
+
4306
+ try {
4307
+ await listCommand(connection, '', '*');
4308
+ test.ok(false, 'Should have thrown');
4309
+ } catch (err) {
4310
+ test.equal(err.code, 'NoConnection');
4311
+ }
4312
+ // A doomed retry against a dead connection is pointless
4313
+ test.equal(listCalls, 1);
4314
+ test.ok(!connection.skipListSubscribedArg);
4315
+ test.done();
4316
+ };
4317
+
4318
+ module.exports['Commands: list does not retry extended LIST on NO responses'] = async test => {
4319
+ let listCalls = 0;
4320
+ const connection = createMockConnection({
4321
+ state: 3,
4322
+ capabilities: new Map([['IMAP4rev2', true]]),
4323
+ exec: async cmd => {
4324
+ if (cmd === 'LIST') {
4325
+ listCalls++;
4326
+ // RFC 9051: unrecognized RETURN options are rejected with BAD;
4327
+ // NO is a transient operational failure
4328
+ throw commandError('Command failed', 'NO');
4329
+ }
4330
+ return { next: () => {} };
4331
+ }
4332
+ });
4333
+
4334
+ try {
4335
+ await listCommand(connection, '', '*');
4336
+ test.ok(false, 'Should have thrown');
4337
+ } catch (err) {
4338
+ test.equal(err.responseStatus, 'NO');
4339
+ }
4340
+ test.equal(listCalls, 1);
4341
+ test.ok(!connection.skipListSubscribedArg);
4342
+ test.done();
4343
+ };
4344
+
4345
+ module.exports['Commands: list does not treat throttling as a RETURN rejection'] = async test => {
4346
+ let listCalls = 0;
4347
+ const connection = createMockConnection({
4348
+ state: 3,
4349
+ capabilities: new Map([['IMAP4rev2', true]]),
4350
+ exec: async cmd => {
4351
+ if (cmd === 'LIST') {
4352
+ listCalls++;
4353
+ // O365-style throttling surfaces as BAD plus code ETHROTTLE
4354
+ throw commandError('Request is throttled', 'BAD', 'ETHROTTLE');
4355
+ }
4356
+ return { next: () => {} };
4357
+ }
4358
+ });
4359
+
4360
+ try {
4361
+ await listCommand(connection, '', '*');
4362
+ test.ok(false, 'Should have thrown');
4363
+ } catch (err) {
4364
+ test.equal(err.code, 'ETHROTTLE');
4365
+ }
4366
+ // Re-issuing against a throttled server and permanently downgrading the
4367
+ // connection would both be wrong
4368
+ test.equal(listCalls, 1);
4369
+ test.ok(!connection.skipListSubscribedArg);
4370
+ test.done();
4371
+ };
4372
+
4373
+ module.exports['Commands: list drops RETURN option groups one stage at a time'] = async test => {
4374
+ let listAttempts = [];
4375
+ let lsubCalled = false;
4376
+ const connection = createMockConnection({
4377
+ state: 3,
4378
+ capabilities: new Map([
4379
+ ['IMAP4rev1', true],
4380
+ ['LIST-EXTENDED', true],
4381
+ ['LIST-STATUS', true]
4382
+ ]),
4383
+ exec: async (cmd, attrs, opts) => {
4384
+ if (cmd === 'LSUB') {
4385
+ lsubCalled = true;
4386
+ }
4387
+ if (cmd === 'LIST') {
4388
+ let flat = JSON.stringify(attrs);
4389
+ listAttempts.push(flat);
4390
+ if (flat.includes('STATUS')) {
4391
+ throw commandError('Command failed', 'BAD');
4392
+ }
4393
+ if (opts && opts.untagged && opts.untagged.LIST) {
4394
+ await opts.untagged.LIST({
4395
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4396
+ });
4397
+ }
4398
+ }
4399
+ return { next: () => {} };
4400
+ }
4401
+ });
4402
+
4403
+ const result = await listCommand(connection, '', '*', { statusQuery: { messages: true } });
4404
+ test.equal(listAttempts.length, 4);
4405
+ // Stage 1: both option groups with the auxiliary options
4406
+ test.ok(listAttempts[0].includes('STATUS'));
4407
+ test.ok(listAttempts[0].includes('SUBSCRIBED'));
4408
+ test.ok(listAttempts[0].includes('CHILDREN'));
4409
+ // Stage 2: the same groups without the auxiliary options - the rejection
4410
+ // might have been about the auxiliaries alone
4411
+ test.ok(listAttempts[1].includes('STATUS'));
4412
+ test.ok(listAttempts[1].includes('SUBSCRIBED'));
4413
+ test.ok(!listAttempts[1].includes('CHILDREN'));
4414
+ // Stage 3: SUBSCRIBED dropped, STATUS kept
4415
+ test.ok(listAttempts[2].includes('STATUS'));
4416
+ test.ok(!listAttempts[2].includes('SUBSCRIBED'));
4417
+ // Stage 4: plain
4418
+ test.ok(!listAttempts[3].includes('RETURN'));
4419
+
4420
+ // Only the group whose removal was followed by success is latched - the BAD of
4421
+ // the earlier stages might have been caused by the STATUS group alone, so
4422
+ // SUBSCRIBED stays unproven and gets retried on the next listing
4423
+ test.equal(connection.skipListStatusArgs, true);
4424
+ test.ok(!connection.skipListSubscribedArg);
4425
+ test.ok(!connection.skipListAuxArgs);
4426
+ test.equal(lsubCalled, true);
4427
+ test.equal(result.length, 1);
4428
+
4429
+ // Next listing converges: SUBSCRIBED-only first, no STATUS args
4430
+ const result2 = await listCommand(connection, '', '*', { statusQuery: { messages: true } });
4431
+ test.equal(listAttempts.length, 5);
4432
+ test.ok(listAttempts[4].includes('SUBSCRIBED'));
4433
+ test.ok(!listAttempts[4].includes('STATUS'));
4434
+ test.equal(result2.length, 1);
4435
+ test.done();
4436
+ };
4437
+
4438
+ module.exports['Commands: list does not latch flags when the reduced retry also dies'] = async test => {
4439
+ let listCalls = 0;
4440
+ const connection = createMockConnection({
4441
+ state: 3,
4442
+ capabilities: new Map([
4443
+ ['IMAP4rev1', true],
4444
+ ['LIST-EXTENDED', true],
4445
+ ['LIST-STATUS', true]
4446
+ ]),
4447
+ exec: async cmd => {
4448
+ if (cmd === 'LIST') {
4449
+ listCalls++;
4450
+ if (listCalls === 1) {
4451
+ throw commandError('Command failed', 'BAD');
4452
+ }
4453
+ // The reduced retry dies on a transport error
4454
+ throw commandError('Connection not available', null, 'NoConnection');
4455
+ }
4456
+ return { next: () => {} };
4457
+ }
4458
+ });
4459
+
4460
+ try {
4461
+ await listCommand(connection, '', '*', { statusQuery: { messages: true } });
4462
+ test.ok(false, 'Should have thrown');
4463
+ } catch (err) {
4464
+ test.equal(err.code, 'NoConnection');
4465
+ }
4466
+ // Nothing was proven - no flag may be latched
4467
+ test.equal(listCalls, 2);
4468
+ test.ok(!connection.skipListSubscribedArg);
4469
+ test.ok(!connection.skipListStatusArgs);
4470
+ test.ok(!connection.skipListAuxArgs);
4471
+ test.done();
4472
+ };
4473
+
4474
+ module.exports['Commands: list tolerates LSUB NO without latching'] = async test => {
4475
+ let lsubCalls = 0;
4476
+ const connection = createMockConnection({
4477
+ state: 3,
4478
+ exec: async (cmd, attrs, opts) => {
4479
+ if (cmd === 'LSUB') {
4480
+ lsubCalls++;
4481
+ // Transient operational failure, not a missing command
4482
+ throw commandError('Server busy', 'NO');
4483
+ }
4484
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4485
+ await opts.untagged.LIST({
4486
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4487
+ });
4488
+ }
4489
+ return { next: () => {} };
4490
+ }
4491
+ });
4492
+
4493
+ const result = await listCommand(connection, '', '*');
4494
+ test.equal(result.length, 1);
4495
+ // NO is transient - the next listing must try LSUB again
4496
+ test.ok(!connection.skipLsub);
4497
+ await listCommand(connection, '', '*');
4498
+ test.equal(lsubCalls, 2);
4499
+ test.done();
4500
+ };
4501
+
4502
+ module.exports['Commands: list rethrows throttled LSUB without latching'] = async test => {
4503
+ const connection = createMockConnection({
4504
+ state: 3,
4505
+ exec: async (cmd, attrs, opts) => {
4506
+ if (cmd === 'LSUB') {
4507
+ throw commandError('Request is throttled', 'BAD', 'ETHROTTLE');
4508
+ }
4509
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4510
+ await opts.untagged.LIST({
4511
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4512
+ });
4513
+ }
4514
+ return { next: () => {} };
4515
+ }
4516
+ });
4517
+
4518
+ try {
4519
+ await listCommand(connection, '', '*');
4520
+ test.ok(false, 'Should have thrown');
4521
+ } catch (err) {
4522
+ test.equal(err.code, 'ETHROTTLE');
4523
+ }
4524
+ // Throttling says nothing about LSUB support
4525
+ test.ok(!connection.skipLsub);
4526
+ test.done();
4527
+ };
4528
+
4529
+ module.exports['Commands: list folds LSUB-delivered Subscribed flag into the property'] = async test => {
4530
+ const connection = createMockConnection({
4531
+ state: 3,
4532
+ exec: async (cmd, attrs, opts) => {
4533
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4534
+ await opts.untagged.LIST({
4535
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4536
+ });
4537
+ }
4538
+ if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
4539
+ // Some servers echo the RFC 5258 \Subscribed attribute in LSUB
4540
+ await opts.untagged.LSUB({
4541
+ attributes: [[{ value: '\\Subscribed' }], { value: '/' }, { value: 'Folder1' }]
4542
+ });
4543
+ }
4544
+ return { next: () => {} };
4545
+ }
4546
+ });
4547
+
4548
+ const result = await listCommand(connection, '', '*');
4549
+ const folder = result.find(e => e.path === 'Folder1');
4550
+ test.equal(folder.subscribed, true);
4551
+ // The flag is folded into the property on the LSUB merge path too
4552
+ test.equal(folder.flags.has('\\Subscribed'), false);
4553
+ test.done();
4554
+ };
4555
+
4556
+ module.exports['Commands: list retries INBOX fixup plain without latching'] = async test => {
4557
+ let listAttempts = [];
4558
+ const connection = createMockConnection({
4559
+ state: 3,
4560
+ capabilities: new Map([
4561
+ ['IMAP4rev1', true],
4562
+ ['LIST-EXTENDED', true]
4563
+ ]),
4564
+ exec: async (cmd, attrs, opts) => {
4565
+ if (cmd === 'LIST') {
4566
+ let flat = JSON.stringify(attrs);
4567
+ listAttempts.push(flat);
4568
+ if (listAttempts.length === 2) {
4569
+ // Fixup call with RETURN args is rejected by a quirky server
4570
+ throw commandError('Command failed', 'BAD');
4571
+ }
4572
+ if (opts && opts.untagged && opts.untagged.LIST) {
4573
+ if (listAttempts.length === 1) {
4574
+ await opts.untagged.LIST({
4575
+ attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '.' }, { value: 'Prefix.Folder1' }]
4576
+ });
4577
+ } else {
4578
+ await opts.untagged.LIST({
4579
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '.' }, { value: 'INBOX' }]
4580
+ });
4581
+ }
4582
+ }
4583
+ }
4584
+ return { next: () => {} };
4585
+ }
4586
+ });
4587
+
4588
+ const result = await listCommand(connection, 'Prefix.', '*');
4589
+ // Main listing + rejected fixup + plain fixup retry
4590
+ test.equal(listAttempts.length, 3);
4591
+ test.ok(listAttempts[1].includes('SUBSCRIBED'));
4592
+ test.ok(!listAttempts[2].includes('RETURN'));
4593
+ // Entries from the successful main run were kept
4594
+ test.ok(result.find(e => e.path === 'Prefix.Folder1'));
4595
+ test.ok(result.find(e => e.path === 'INBOX'));
4596
+ // The main run succeeded with the same RETURN args - nothing may be latched
4597
+ test.ok(!connection.skipListSubscribedArg);
4598
+ test.ok(!connection.skipListStatusArgs);
4599
+ test.done();
4600
+ };
4601
+
4602
+ module.exports['Commands: list discards partial results from a rejected INBOX fixup'] = async test => {
4603
+ let listAttempts = [];
4604
+ const connection = createMockConnection({
4605
+ state: 3,
4606
+ capabilities: new Map([
4607
+ ['IMAP4rev1', true],
4608
+ ['LIST-EXTENDED', true]
4609
+ ]),
4610
+ exec: async (cmd, attrs, opts) => {
4611
+ if (cmd === 'LIST') {
4612
+ listAttempts.push(JSON.stringify(attrs));
4613
+ if (listAttempts.length === 1) {
4614
+ await opts.untagged.LIST({
4615
+ attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '.' }, { value: 'Prefix.Folder1' }]
4616
+ });
4617
+ } else if (listAttempts.length === 2) {
4618
+ // The fixup attempt streams an untagged INBOX line and THEN gets
4619
+ // the tagged BAD - the partial line must not survive the retry
4620
+ await opts.untagged.LIST({
4621
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '.' }, { value: 'INBOX' }]
4622
+ });
4623
+ throw commandError('Command failed', 'BAD');
4624
+ } else {
4625
+ await opts.untagged.LIST({
4626
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '.' }, { value: 'INBOX' }]
4627
+ });
4628
+ }
4629
+ }
4630
+ return { next: () => {} };
4631
+ }
4632
+ });
4633
+
4634
+ const result = await listCommand(connection, 'Prefix.', '*');
4635
+ test.equal(listAttempts.length, 3);
4636
+ // Exactly one INBOX entry - the rejected attempt's partial line was discarded
4637
+ test.equal(result.filter(e => e.path === 'INBOX').length, 1);
4638
+ test.ok(result.find(e => e.path === 'Prefix.Folder1'));
4639
+ test.done();
4640
+ };
4641
+
4642
+ module.exports['Commands: list latches only the auxiliary options when the server rejects them'] = async test => {
4643
+ let listAttempts = [];
3451
4644
  const connection = createMockConnection({
3452
4645
  state: 3,
3453
- capabilities: new Map(),
3454
- run: async cmd => {
3455
- if (cmd === 'STATUS') {
3456
- throw new Error('Status failed');
3457
- }
3458
- },
4646
+ capabilities: new Map([
4647
+ ['IMAP4rev1', true],
4648
+ ['LIST-EXTENDED', true],
4649
+ ['LIST-STATUS', true],
4650
+ ['SPECIAL-USE', true]
4651
+ ]),
3459
4652
  exec: async (cmd, attrs, opts) => {
3460
- if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4653
+ if (cmd === 'LIST') {
4654
+ let flat = JSON.stringify(attrs);
4655
+ listAttempts.push(flat);
4656
+ if (flat.includes('SPECIAL-USE') || flat.includes('CHILDREN')) {
4657
+ throw commandError('Command failed', 'BAD');
4658
+ }
3461
4659
  await opts.untagged.LIST({
3462
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4660
+ attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
3463
4661
  });
4662
+ if (opts.untagged.STATUS) {
4663
+ await opts.untagged.STATUS({
4664
+ attributes: [{ value: 'Folder1' }, [{ value: 'MESSAGES' }, { value: '3' }]]
4665
+ });
4666
+ }
3464
4667
  }
3465
4668
  return { next: () => {} };
3466
4669
  }
3467
4670
  });
3468
4671
 
3469
- const result = await listCommand(connection, '', '*', {
3470
- statusQuery: { messages: true }
3471
- });
3472
- const inbox = result.find(e => e.path === 'INBOX');
3473
- test.ok(inbox);
3474
- // Status should have error property
3475
- test.ok(inbox.status);
3476
- test.ok(inbox.status.error);
4672
+ const result = await listCommand(connection, '', '*', { statusQuery: { messages: true } });
4673
+ // Extended attempt with auxiliaries, then the same groups without them
4674
+ test.equal(listAttempts.length, 2);
4675
+ test.ok(listAttempts[0].includes('SPECIAL-USE'));
4676
+ test.ok(listAttempts[1].includes('STATUS'));
4677
+ test.ok(listAttempts[1].includes('SUBSCRIBED'));
4678
+ test.ok(!listAttempts[1].includes('SPECIAL-USE'));
4679
+ test.ok(!listAttempts[1].includes('CHILDREN'));
4680
+ // Only the auxiliaries are latched - both option groups survived intact
4681
+ test.equal(connection.skipListAuxArgs, true);
4682
+ test.ok(!connection.skipListSubscribedArg);
4683
+ test.ok(!connection.skipListStatusArgs);
4684
+ let folder = result.find(e => e.path === 'Folder1');
4685
+ test.equal(folder.subscribed, true);
4686
+ test.equal(folder.status.messages, 3);
4687
+
4688
+ // The next listing goes straight to the auxiliary-free extended form
4689
+ await listCommand(connection, '', '*', { statusQuery: { messages: true } });
4690
+ test.equal(listAttempts.length, 3);
4691
+ test.ok(listAttempts[2].includes('STATUS'));
4692
+ test.ok(listAttempts[2].includes('SUBSCRIBED'));
4693
+ test.ok(!listAttempts[2].includes('SPECIAL-USE'));
3477
4694
  test.done();
3478
4695
  };
3479
4696
 
3480
- module.exports['Commands: list sorts by special use'] = async test => {
4697
+ module.exports['Commands: list falls back to LSUB when RETURN (SUBSCRIBED) is silently ignored'] = async test => {
4698
+ let lsubCalled = false;
3481
4699
  const connection = createMockConnection({
3482
4700
  state: 3,
3483
- capabilities: new Map([['SPECIAL-USE', true]]),
4701
+ capabilities: new Map([
4702
+ ['IMAP4rev1', true],
4703
+ ['LIST-EXTENDED', true]
4704
+ ]),
3484
4705
  exec: async (cmd, attrs, opts) => {
3485
- if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3486
- // Add folders out of order
3487
- await opts.untagged.LIST({
3488
- attributes: [[{ value: '\\Trash' }], { value: '/' }, { value: 'Trash' }]
3489
- });
3490
- await opts.untagged.LIST({
3491
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4706
+ if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
4707
+ lsubCalled = true;
4708
+ await opts.untagged.LSUB({
4709
+ attributes: [[], { value: '/' }, { value: 'Folder1' }]
3492
4710
  });
4711
+ }
4712
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4713
+ // Server accepted RETURN (SUBSCRIBED) but returned no \Subscribed flags
3493
4714
  await opts.untagged.LIST({
3494
- attributes: [[{ value: '\\Sent' }], { value: '/' }, { value: 'Sent' }]
4715
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
3495
4716
  });
3496
4717
  }
3497
4718
  return { next: () => {} };
@@ -3499,118 +4720,127 @@ module.exports['Commands: list sorts by special use'] = async test => {
3499
4720
  });
3500
4721
 
3501
4722
  const result = await listCommand(connection, '', '*');
3502
- // INBOX should be first (has \\Inbox special use)
3503
- test.equal(result[0].specialUse, '\\Inbox');
4723
+ test.equal(lsubCalled, true);
4724
+ test.equal(result.find(e => e.path === 'Folder1').subscribed, true);
3504
4725
  test.done();
3505
4726
  };
3506
4727
 
3507
- module.exports['Commands: list handles delimiter in path'] = async test => {
4728
+ module.exports['Commands: list skips the LSUB safety net on rev2 sessions'] = async test => {
4729
+ let lsubCalled = false;
3508
4730
  const connection = createMockConnection({
3509
4731
  state: 3,
4732
+ capabilities: new Map([['IMAP4rev2', true]]),
3510
4733
  exec: async (cmd, attrs, opts) => {
4734
+ if (cmd === 'LSUB') {
4735
+ lsubCalled = true;
4736
+ }
3511
4737
  if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4738
+ // No folder is subscribed - legitimate on a fresh account, and rev2
4739
+ // removed LSUB so there is nothing to fall back to
3512
4740
  await opts.untagged.LIST({
3513
- attributes: [
3514
- [{ value: '\\HasNoChildren' }],
3515
- { value: '/' },
3516
- { value: '/Leading/Slash' } // Path starts with delimiter
3517
- ]
4741
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
3518
4742
  });
3519
4743
  }
3520
4744
  return { next: () => {} };
3521
4745
  }
3522
4746
  });
3523
4747
 
3524
- const result = await listCommand(connection, '', '*');
3525
- const folder = result.find(e => e.name === 'Slash');
3526
- test.ok(folder);
3527
- // Leading delimiter should be removed
3528
- test.equal(folder.path, 'Leading/Slash');
4748
+ await listCommand(connection, '', '*');
4749
+ test.equal(lsubCalled, false);
3529
4750
  test.done();
3530
4751
  };
3531
4752
 
3532
- module.exports['Commands: list skips Noselect folders for status'] = async test => {
3533
- let statusCalls = 0;
4753
+ module.exports['Commands: list honors special-use flags on rev2-only servers'] = async test => {
3534
4754
  const connection = createMockConnection({
3535
4755
  state: 3,
3536
- capabilities: new Map(),
3537
- run: async cmd => {
3538
- if (cmd === 'STATUS') {
3539
- statusCalls++;
3540
- return { messages: 10 };
3541
- }
3542
- },
4756
+ capabilities: new Map([['IMAP4rev2', true]]),
3543
4757
  exec: async (cmd, attrs, opts) => {
3544
4758
  if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4759
+ // RFC 9051 folds the RFC 6154 attributes into base rev2 - no separate
4760
+ // SPECIAL-USE capability token is required
3545
4761
  await opts.untagged.LIST({
3546
- attributes: [[{ value: '\\Noselect' }], { value: '/' }, { value: 'Parent' }]
4762
+ attributes: [[{ value: '\\Sent' }, { value: '\\HasNoChildren' }], { value: '/' }, { value: 'Custom-Sent-Name' }]
3547
4763
  });
3548
4764
  }
3549
4765
  return { next: () => {} };
3550
4766
  }
3551
4767
  });
3552
4768
 
3553
- await listCommand(connection, '', '*', { statusQuery: { messages: true } });
3554
- // STATUS should not be called for Noselect folders
3555
- test.equal(statusCalls, 0);
4769
+ const result = await listCommand(connection, '', '*');
4770
+ const folder = result.find(e => e.path === 'Custom-Sent-Name');
4771
+ test.equal(folder.specialUse, '\\Sent');
3556
4772
  test.done();
3557
4773
  };
3558
4774
 
3559
- module.exports['Commands: list adds Noselect to NonExistent mailboxes'] = async test => {
4775
+ module.exports['Commands: list uses inline STATUS on rev2-only servers and omits RECENT'] = async test => {
4776
+ let listAttrs = false;
4777
+ let statusCommands = 0;
3560
4778
  const connection = createMockConnection({
3561
4779
  state: 3,
4780
+ capabilities: new Map([['IMAP4rev2', true]]),
4781
+ run: async cmd => {
4782
+ if (cmd === 'STATUS') {
4783
+ statusCommands++;
4784
+ }
4785
+ return {};
4786
+ },
3562
4787
  exec: async (cmd, attrs, opts) => {
3563
- if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3564
- await opts.untagged.LIST({
3565
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3566
- });
3567
- await opts.untagged.LIST({
3568
- attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Phantom' }]
3569
- });
4788
+ if (cmd === 'LIST') {
4789
+ listAttrs = JSON.stringify(attrs);
4790
+ if (opts && opts.untagged && opts.untagged.LIST) {
4791
+ await opts.untagged.LIST({
4792
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4793
+ });
4794
+ }
4795
+ if (opts && opts.untagged && opts.untagged.STATUS) {
4796
+ await opts.untagged.STATUS({
4797
+ attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '5' }]]
4798
+ });
4799
+ }
3570
4800
  }
3571
4801
  return { next: () => {} };
3572
4802
  }
3573
4803
  });
3574
4804
 
3575
- const result = await listCommand(connection, '', '*');
3576
- const phantom = result.find(e => e.path === 'Phantom');
3577
- test.ok(phantom);
3578
- // RFC 5258: \\NonExistent implies \\Noselect
3579
- test.equal(phantom.flags.has('\\Noselect'), true);
3580
- // The original flag is preserved, not replaced
3581
- test.equal(phantom.flags.has('\\NonExistent'), true);
3582
- const inbox = result.find(e => e.path === 'INBOX');
3583
- test.ok(inbox);
3584
- test.equal(inbox.flags.has('\\Noselect'), false);
4805
+ const result = await listCommand(connection, '', '*', { statusQuery: { messages: true, recent: true } });
4806
+ // LIST-STATUS is part of base rev2, so STATUS data arrives inline
4807
+ test.ok(listAttrs.includes('STATUS'));
4808
+ // RECENT was removed in rev2 and must not be requested
4809
+ test.ok(!listAttrs.includes('RECENT'));
4810
+ test.equal(statusCommands, 0);
4811
+ test.equal(result.find(e => e.path === 'INBOX').status.messages, 5);
4812
+ // The requested recent value is synthesized - rev2 defines it as always 0
4813
+ test.equal(result.find(e => e.path === 'INBOX').status.recent, 0);
3585
4814
  test.done();
3586
4815
  };
3587
4816
 
3588
- module.exports['Commands: list LSUB merge adds Noselect to NonExistent'] = async test => {
4817
+ module.exports['Commands: list does not let NonExistent phantoms win special-use by name'] = async test => {
3589
4818
  const connection = createMockConnection({
3590
4819
  state: 3,
3591
4820
  exec: async (cmd, attrs, opts) => {
3592
4821
  if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4822
+ // Phantom subscription leftover of a deleted folder
3593
4823
  await opts.untagged.LIST({
3594
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4824
+ attributes: [[{ value: '\\NonExistent' }, { value: '\\Subscribed' }], { value: '/' }, { value: 'Sent' }]
3595
4825
  });
3596
- }
3597
- if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
3598
- // Some servers only report \\NonExistent in LSUB responses
3599
- await opts.untagged.LSUB({
3600
- attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Folder1' }]
4826
+ // The real sent-mail folder, matched by name
4827
+ await opts.untagged.LIST({
4828
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Sent Messages' }]
3601
4829
  });
3602
4830
  }
4831
+ if (cmd === 'LSUB') {
4832
+ throw commandError('Command failed', 'BAD');
4833
+ }
3603
4834
  return { next: () => {} };
3604
4835
  }
3605
4836
  });
3606
4837
 
3607
4838
  const result = await listCommand(connection, '', '*');
3608
- const folder = result.find(e => e.path === 'Folder1');
3609
- test.ok(folder);
3610
- test.equal(folder.subscribed, true);
3611
- test.equal(folder.flags.has('\\NonExistent'), true);
3612
- // RFC 5258: \\NonExistent merged from LSUB implies \\Noselect
3613
- test.equal(folder.flags.has('\\Noselect'), true);
4839
+ const phantom = result.find(e => e.path === 'Sent');
4840
+ const real = result.find(e => e.path === 'Sent Messages');
4841
+ test.ok(phantom);
4842
+ test.notEqual(phantom.specialUse, '\\Sent');
4843
+ test.equal(real.specialUse, '\\Sent');
3614
4844
  test.done();
3615
4845
  };
3616
4846
 
@@ -4673,6 +5903,150 @@ module.exports['Commands: status skips when no query attributes'] = async test =
4673
5903
  test.done();
4674
5904
  };
4675
5905
 
5906
+ module.exports['Commands: status returns synthetic recent on rev2 sessions'] = async test => {
5907
+ let execCalled = false;
5908
+ const connection = createMockConnection({
5909
+ state: 2,
5910
+ capabilities: new Map([['IMAP4rev2', true]]),
5911
+ exec: async () => {
5912
+ execCalled = true;
5913
+ return { next: () => {} };
5914
+ }
5915
+ });
5916
+
5917
+ // RECENT does not exist in IMAP4rev2 - the caller still gets a status object
5918
+ // (recent is 0 by definition) instead of false, and no command is sent
5919
+ const result = await statusCommand(connection, 'INBOX', { recent: true });
5920
+ test.equal(execCalled, false);
5921
+ test.deepEqual(result, { path: 'INBOX', recent: 0 });
5922
+ test.done();
5923
+ };
5924
+
5925
+ module.exports['Commands: status merges synthetic recent into rev2 query results'] = async test => {
5926
+ let queryAttrs = null;
5927
+ const connection = createMockConnection({
5928
+ state: 2,
5929
+ capabilities: new Map([['IMAP4rev2', true]]),
5930
+ exec: async (cmd, attrs, opts) => {
5931
+ queryAttrs = JSON.stringify(attrs);
5932
+ await opts.untagged.STATUS({
5933
+ attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '100' }]]
5934
+ });
5935
+ return { next: () => {} };
5936
+ }
5937
+ });
5938
+
5939
+ const result = await statusCommand(connection, 'INBOX', { messages: true, recent: true });
5940
+ // RECENT must not be requested from a rev2 session, but the result keeps the
5941
+ // rev1 shape for the same query
5942
+ test.ok(!queryAttrs.includes('RECENT'));
5943
+ test.equal(result.messages, 100);
5944
+ test.equal(result.recent, 0);
5945
+ test.done();
5946
+ };
5947
+
5948
+ module.exports['Commands: status requests and parses SIZE and DELETED on rev2 sessions'] = async test => {
5949
+ let queryAttrs = null;
5950
+ const connection = createMockConnection({
5951
+ state: 2,
5952
+ // rev2-only server: STATUS=SIZE is folded in and DELETED is a base rev2
5953
+ // status item (RFC 9051 Appendix E item 3)
5954
+ capabilities: new Map([['IMAP4rev2', true]]),
5955
+ exec: async (cmd, attrs, opts) => {
5956
+ queryAttrs = JSON.stringify(attrs);
5957
+ await opts.untagged.STATUS({
5958
+ attributes: [
5959
+ { value: 'INBOX' },
5960
+ [{ value: 'MESSAGES' }, { value: '100' }, { value: 'SIZE' }, { value: '12345678901234' }, { value: 'DELETED' }, { value: '3' }]
5961
+ ]
5962
+ });
5963
+ return { next: () => {} };
5964
+ }
5965
+ });
5966
+
5967
+ const result = await statusCommand(connection, 'INBOX', { messages: true, size: true, deleted: true });
5968
+ test.ok(queryAttrs.includes('SIZE'));
5969
+ test.ok(queryAttrs.includes('DELETED'));
5970
+ test.equal(result.messages, 100);
5971
+ // STATUS SIZE is a number64 - values beyond 2^32 must survive
5972
+ test.strictEqual(result.size, 12345678901234);
5973
+ test.strictEqual(result.deleted, 3);
5974
+ test.done();
5975
+ };
5976
+
5977
+ module.exports['Commands: status requests SIZE with the STATUS=SIZE token on rev1 sessions'] = async test => {
5978
+ let queryAttrs = null;
5979
+ const connection = createMockConnection({
5980
+ state: 2,
5981
+ // RFC 8438 server: SIZE is available via the capability token, DELETED is
5982
+ // rev2-only and must be dropped
5983
+ capabilities: new Map([
5984
+ ['IMAP4rev1', true],
5985
+ ['STATUS=SIZE', true]
5986
+ ]),
5987
+ exec: async (cmd, attrs, opts) => {
5988
+ queryAttrs = JSON.stringify(attrs);
5989
+ await opts.untagged.STATUS({
5990
+ attributes: [{ value: 'INBOX' }, [{ value: 'SIZE' }, { value: '2048' }]]
5991
+ });
5992
+ return { next: () => {} };
5993
+ }
5994
+ });
5995
+
5996
+ const result = await statusCommand(connection, 'INBOX', { size: true, deleted: true });
5997
+ test.ok(queryAttrs.includes('SIZE'));
5998
+ test.ok(!queryAttrs.includes('DELETED'));
5999
+ test.strictEqual(result.size, 2048);
6000
+ test.done();
6001
+ };
6002
+
6003
+ module.exports['Commands: status requests DELETED with QUOTA=RES-MESSAGE on rev1 sessions'] = async test => {
6004
+ let queryAttrs = null;
6005
+ const connection = createMockConnection({
6006
+ state: 2,
6007
+ // RFC 9208: the DELETED status item is mandatory when QUOTA=RES-MESSAGE
6008
+ // is advertised, even without IMAP4rev2
6009
+ capabilities: new Map([
6010
+ ['IMAP4rev1', true],
6011
+ ['QUOTA=RES-MESSAGE', true]
6012
+ ]),
6013
+ exec: async (cmd, attrs, opts) => {
6014
+ queryAttrs = JSON.stringify(attrs);
6015
+ await opts.untagged.STATUS({
6016
+ attributes: [{ value: 'INBOX' }, [{ value: 'DELETED' }, { value: '4' }]]
6017
+ });
6018
+ return { next: () => {} };
6019
+ }
6020
+ });
6021
+
6022
+ const result = await statusCommand(connection, 'INBOX', { deleted: true });
6023
+ test.ok(queryAttrs.includes('DELETED'));
6024
+ test.strictEqual(result.deleted, 4);
6025
+ test.done();
6026
+ };
6027
+
6028
+ module.exports['Commands: status drops SIZE and DELETED on rev1 sessions without support'] = async test => {
6029
+ let queryAttrs = null;
6030
+ const connection = createMockConnection({
6031
+ state: 2,
6032
+ exec: async (cmd, attrs, opts) => {
6033
+ queryAttrs = JSON.stringify(attrs);
6034
+ await opts.untagged.STATUS({
6035
+ attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '100' }]]
6036
+ });
6037
+ return { next: () => {} };
6038
+ }
6039
+ });
6040
+
6041
+ // requesting them must not poison the whole STATUS command on a server that
6042
+ // does not know these items
6043
+ const result = await statusCommand(connection, 'INBOX', { messages: true, size: true, deleted: true });
6044
+ test.ok(!queryAttrs.includes('SIZE'));
6045
+ test.ok(!queryAttrs.includes('DELETED'));
6046
+ test.equal(result.messages, 100);
6047
+ test.done();
6048
+ };
6049
+
4676
6050
  module.exports['Commands: status skips when all query values are false'] = async test => {
4677
6051
  const connection = createMockConnection({ state: 2 });
4678
6052
 
@@ -5772,6 +7146,27 @@ module.exports['Commands: idle with IDLE capability'] = async test => {
5772
7146
  test.done();
5773
7147
  };
5774
7148
 
7149
+ module.exports['Commands: idle uses IDLE on rev2-only servers without the IDLE token'] = async test => {
7150
+ let execCommand = '';
7151
+ const connection = createMockConnection({
7152
+ state: 3,
7153
+ // IDLE is part of base IMAP4rev2 - no separate token required
7154
+ capabilities: new Map([['IMAP4rev2', true]]),
7155
+ exec: async (cmd, attrs, opts) => {
7156
+ execCommand = cmd;
7157
+ if (opts && opts.onPlusTag) {
7158
+ await opts.onPlusTag();
7159
+ }
7160
+ return { next: () => {} };
7161
+ },
7162
+ write: () => {}
7163
+ });
7164
+
7165
+ await idleCommand(connection);
7166
+ test.equal(execCommand, 'IDLE');
7167
+ test.done();
7168
+ };
7169
+
5775
7170
  module.exports['Commands: idle skips when not selected'] = async test => {
5776
7171
  const connection = createMockConnection({ state: 2 }); // AUTHENTICATED
5777
7172
 
@@ -6501,6 +7896,30 @@ module.exports['Commands: namespace with NAMESPACE capability'] = async test =>
6501
7896
  test.done();
6502
7897
  };
6503
7898
 
7899
+ module.exports['Commands: namespace uses the real command on rev2-only servers without the token'] = async test => {
7900
+ const connection = createMockConnection({
7901
+ state: 2,
7902
+ // NAMESPACE is folded into base IMAP4rev2 (RFC 9051 Appendix E) - a
7903
+ // rev2-only server gets a real NAMESPACE command, not the LIST fallback
7904
+ capabilities: new Map([['IMAP4rev2', true]]),
7905
+ exec: async (cmd, args, opts) => {
7906
+ test.equal(cmd, 'NAMESPACE');
7907
+ if (opts && opts.untagged && opts.untagged.NAMESPACE) {
7908
+ await opts.untagged.NAMESPACE({
7909
+ attributes: [[[{ value: '' }, { value: '/' }]], null, null]
7910
+ });
7911
+ }
7912
+ return { next: () => {} };
7913
+ }
7914
+ });
7915
+
7916
+ const result = await namespaceCommand(connection);
7917
+ test.ok(result);
7918
+ test.equal(result.prefix, '');
7919
+ test.equal(result.delimiter, '/');
7920
+ test.done();
7921
+ };
7922
+
6504
7923
  module.exports['Commands: namespace fallback without capability'] = async test => {
6505
7924
  const connection = createMockConnection({
6506
7925
  state: 2, // AUTHENTICATED
@@ -8608,10 +10027,11 @@ module.exports['Commands: enable updates connection.enabled'] = async test => {
8608
10027
  });
8609
10028
 
8610
10029
  await enableCommand(connection, ['CONDSTORE', 'UTF8=ACCEPT']);
8611
- // connection.enabled should be replaced with new set
10030
+ // New grants are merged in; earlier grants survive because the untagged
10031
+ // ENABLED response only lists extensions enabled by this command (RFC 5161)
8612
10032
  test.ok(connection.enabled.has('CONDSTORE'));
8613
10033
  test.ok(connection.enabled.has('UTF8=ACCEPT'));
8614
- test.ok(!connection.enabled.has('EXISTING')); // Old value should be gone
10034
+ test.ok(connection.enabled.has('EXISTING'));
8615
10035
  test.done();
8616
10036
  };
8617
10037