imapflow 1.4.7 → 1.4.9

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 (38) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/CLAUDE.md +10 -1
  4. package/Gruntfile.js +3 -1
  5. package/lib/commands/authenticate.js +13 -1
  6. package/lib/commands/enable.js +13 -4
  7. package/lib/commands/expunge.js +2 -2
  8. package/lib/commands/idle.js +6 -3
  9. package/lib/commands/list.js +237 -60
  10. package/lib/commands/move.js +2 -2
  11. package/lib/commands/namespace.js +3 -1
  12. package/lib/commands/search.js +88 -13
  13. package/lib/commands/status.js +13 -25
  14. package/lib/imap-flow.d.ts +5 -3
  15. package/lib/imap-flow.js +43 -9
  16. package/lib/search-compiler.js +15 -1
  17. package/lib/tools.js +141 -9
  18. package/package.json +7 -6
  19. package/test/commands-branches-test.js +11 -4
  20. package/test/commands-integration-test.js +1270 -124
  21. package/test/connection-edge-cases-test.js +2 -2
  22. package/test/connection-test.js +1 -1
  23. package/test/fixtures/test-tls.js +2 -2
  24. package/test/imap-flow-coverage-test.js +8 -1
  25. package/test/imap-flow-fetch-download-test.js +1 -4
  26. package/test/imap-flow-internals-test.js +2 -2
  27. package/test/imap-flow-methods-test.js +65 -6
  28. package/test/imap-parser-test.js +1 -2
  29. package/test/integration/README.md +40 -0
  30. package/test/integration/dovecot-test.conf +27 -0
  31. package/test/integration/rev2-live-test.js +242 -0
  32. package/test/integration/run-rev2-tests.sh +61 -0
  33. package/test/reliability-improvements-test.js +4 -1
  34. package/test/search-compiler-test.js +19 -0
  35. package/test/search-test.js +52 -54
  36. package/test/tools-test.js +134 -15
  37. package/.github/codeql/codeql-config.yml +0 -12
  38. package/.github/workflows/codeql.yml +0 -102
@@ -30,6 +30,9 @@ const createMockConnection = (overrides = {}) => {
30
30
  states,
31
31
  state: overrides.state || states.SELECTED,
32
32
  id: 'test-connection-id',
33
+ // Mirrors imap-flow.js, which always resolves a port before authenticating. Without a
34
+ // default the OAUTHBEARER payload builds `port=undefined`.
35
+ port: overrides.port || 993,
33
36
  capabilities: new Map(overrides.capabilities || [['IMAP4rev1', true]]),
34
37
  enabled: new Set(overrides.enabled || []),
35
38
  authCapabilities: new Map(),
@@ -46,6 +49,10 @@ const createMockConnection = (overrides = {}) => {
46
49
  close: overrides.close || (() => {}),
47
50
  emit: overrides.emit || (() => {}),
48
51
  currentSelectCommand: false,
52
+ skipListSubscribedArg: false,
53
+ skipListStatusArgs: false,
54
+ skipListAuxArgs: false,
55
+ skipLsub: false,
49
56
  messageFlagsAdd: overrides.messageFlagsAdd || (async () => {}),
50
57
  run: overrides.run || (async () => {}),
51
58
  exec:
@@ -58,6 +65,23 @@ const createMockConnection = (overrides = {}) => {
58
65
  };
59
66
  };
60
67
 
68
+ // Decodes the base64 SASL payload that authenticate() hands to exec().
69
+ const decodeSaslPayload = execArgs => Buffer.from(execArgs.args[1].value, 'base64').toString();
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
+
61
85
  // ============================================
62
86
  // CAPABILITY Command Tests
63
87
  // ============================================
@@ -436,6 +460,109 @@ module.exports['Commands: search with ALL'] = async test => {
436
460
  test.done();
437
461
  };
438
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 caps a hostile ESEARCH ALL range at the mailbox size'] = async test => {
492
+ const connection = createMockConnection({
493
+ state: 3,
494
+ capabilities: new Map([['IMAP4rev2', true]]),
495
+ mailbox: { path: 'INBOX', exists: 100 },
496
+ exec: async (cmd, attrs, opts) => {
497
+ // A few bytes of hostile response must not expand into billions of ids
498
+ await opts.untagged.ESEARCH({
499
+ attributes: [
500
+ { type: 'ATOM', value: 'ALL' },
501
+ { type: 'ATOM', value: '1:4294967295' }
502
+ ]
503
+ });
504
+ return { next: () => {} };
505
+ }
506
+ });
507
+
508
+ const result = await searchCommand(connection, true, {});
509
+ // A conforming server cannot match more messages than the mailbox holds
510
+ test.equal(result.length, 100);
511
+ test.equal(result[0], 1);
512
+ test.equal(result[99], 100);
513
+ test.done();
514
+ };
515
+
516
+ module.exports['Commands: search resolves * in an ESEARCH ALL sequence-set'] = async test => {
517
+ const connection = createMockConnection({
518
+ state: 3,
519
+ capabilities: new Map([['IMAP4rev2', true]]),
520
+ mailbox: { path: 'INBOX', exists: 5 },
521
+ exec: async (cmd, attrs, opts) => {
522
+ await opts.untagged.ESEARCH({
523
+ attributes: [
524
+ { type: 'ATOM', value: 'ALL' },
525
+ { type: 'ATOM', value: '3:*' }
526
+ ]
527
+ });
528
+ return { next: () => {} };
529
+ }
530
+ });
531
+
532
+ const result = await searchCommand(connection, true, {});
533
+ // '*' means the largest sequence number in use, which is the EXISTS count
534
+ test.deepEqual(result, [3, 4, 5]);
535
+ test.done();
536
+ };
537
+
538
+ module.exports['Commands: search drops * from ESEARCH UID results'] = async test => {
539
+ const connection = createMockConnection({
540
+ state: 3,
541
+ capabilities: new Map([['IMAP4rev2', true]]),
542
+ mailbox: { path: 'INBOX', exists: 5, uidNext: 1000 },
543
+ exec: async (cmd, attrs, opts) => {
544
+ await opts.untagged.ESEARCH({
545
+ attributes: [
546
+ [
547
+ { type: 'ATOM', value: 'TAG' },
548
+ { type: 'STRING', value: 'A1' }
549
+ ],
550
+ { type: 'ATOM', value: 'UID' },
551
+ { type: 'ATOM', value: 'ALL' },
552
+ { type: 'ATOM', value: '7,3:*' }
553
+ ]
554
+ });
555
+ return { next: () => {} };
556
+ }
557
+ });
558
+
559
+ const result = await searchCommand(connection, true, { uid: true });
560
+ // Server-sent UID sets may not contain '*' (RFC 9051 4.1.1) - the offending
561
+ // part is dropped, valid parts are kept
562
+ test.deepEqual(result, [7]);
563
+ test.done();
564
+ };
565
+
439
566
  module.exports['Commands: search with UID option'] = async test => {
440
567
  let execCmd = null;
441
568
  const connection = createMockConnection({
@@ -1202,6 +1329,23 @@ module.exports['Commands: move with UID and MOVE capability'] = async test => {
1202
1329
  test.done();
1203
1330
  };
1204
1331
 
1332
+ module.exports['Commands: move uses MOVE via folded rev2 capability'] = async test => {
1333
+ let execCmd = null;
1334
+ const connection = createMockConnection({
1335
+ state: 3,
1336
+ // No MOVE token - RFC 9051 folds MOVE into base IMAP4rev2
1337
+ capabilities: new Map([['IMAP4rev2', true]]),
1338
+ exec: async cmd => {
1339
+ execCmd = cmd;
1340
+ return { next: () => {}, response: { attributes: [] } };
1341
+ }
1342
+ });
1343
+
1344
+ await moveCommand(connection, '1:10', 'Archive', {});
1345
+ test.equal(execCmd, 'MOVE');
1346
+ test.done();
1347
+ };
1348
+
1205
1349
  module.exports['Commands: move skips when not selected'] = async test => {
1206
1350
  const connection = createMockConnection({ state: 2 });
1207
1351
 
@@ -1555,6 +1699,25 @@ module.exports['Commands: expunge with UID range'] = async test => {
1555
1699
  test.done();
1556
1700
  };
1557
1701
 
1702
+ module.exports['Commands: expunge uses UID EXPUNGE via folded rev2 capability'] = async test => {
1703
+ let execCmd = null;
1704
+ const connection = createMockConnection({
1705
+ state: 3,
1706
+ // No UIDPLUS token - RFC 9051 folds UIDPLUS into base IMAP4rev2. Falling
1707
+ // back to plain EXPUNGE here would purge every \Deleted message instead
1708
+ // of only the requested range.
1709
+ capabilities: new Map([['IMAP4rev2', true]]),
1710
+ exec: async cmd => {
1711
+ execCmd = cmd;
1712
+ return { next: () => {}, response: { attributes: [] } };
1713
+ }
1714
+ });
1715
+
1716
+ await expungeCommand(connection, '1:100', { uid: true });
1717
+ test.equal(execCmd, 'UID EXPUNGE');
1718
+ test.done();
1719
+ };
1720
+
1558
1721
  module.exports['Commands: expunge skips when not selected'] = async test => {
1559
1722
  const connection = createMockConnection({ state: 2 });
1560
1723
 
@@ -2474,6 +2637,79 @@ module.exports['Commands: enable handles error'] = async test => {
2474
2637
  test.done();
2475
2638
  };
2476
2639
 
2640
+ module.exports['Commands: enable passes IMAP4rev2 through the capability prefilter'] = async test => {
2641
+ let enableAttrs = null;
2642
+ const connection = createMockConnection({
2643
+ state: 2,
2644
+ capabilities: new Map([
2645
+ ['ENABLE', true],
2646
+ ['IMAP4rev1', true],
2647
+ // Canonical mixed-case key as stored by updateCapabilities
2648
+ ['IMAP4rev2', true]
2649
+ ]),
2650
+ exec: async (cmd, attrs, opts) => {
2651
+ enableAttrs = attrs;
2652
+ if (opts && opts.untagged && opts.untagged.ENABLED) {
2653
+ await opts.untagged.ENABLED({ attributes: [{ value: 'IMAP4rev2' }] });
2654
+ }
2655
+ return { next: () => {} };
2656
+ }
2657
+ });
2658
+
2659
+ const result = await enableCommand(connection, ['IMAP4rev2']);
2660
+ // The mixed-case capability key must not trip the case-sensitive lookup
2661
+ test.ok(enableAttrs);
2662
+ test.ok(enableAttrs.some(attr => attr.value === 'IMAP4REV2'));
2663
+ test.ok(result.has('IMAP4REV2'));
2664
+ test.done();
2665
+ };
2666
+
2667
+ module.exports['Commands: enable merges into previously enabled extensions'] = async test => {
2668
+ const connection = createMockConnection({
2669
+ state: 2,
2670
+ capabilities: new Map([
2671
+ ['ENABLE', true],
2672
+ ['IMAP4rev1', true],
2673
+ ['IMAP4rev2', true]
2674
+ ]),
2675
+ enabled: new Set(['CONDSTORE']),
2676
+ exec: async (cmd, attrs, opts) => {
2677
+ if (opts && opts.untagged && opts.untagged.ENABLED) {
2678
+ await opts.untagged.ENABLED({ attributes: [{ value: 'IMAP4rev2' }] });
2679
+ }
2680
+ return { next: () => {} };
2681
+ }
2682
+ });
2683
+
2684
+ await enableCommand(connection, ['IMAP4rev2']);
2685
+ // The ENABLED response only lists newly enabled extensions - earlier grants
2686
+ // must survive
2687
+ test.ok(connection.enabled.has('CONDSTORE'));
2688
+ test.ok(connection.enabled.has('IMAP4REV2'));
2689
+ test.done();
2690
+ };
2691
+
2692
+ module.exports['Commands: enable works without the ENABLE token on rev2-only servers'] = async test => {
2693
+ let execCalled = false;
2694
+ const connection = createMockConnection({
2695
+ state: 2,
2696
+ // ENABLE is part of base IMAP4rev2 - rev2-only servers may omit the token
2697
+ capabilities: new Map([['IMAP4rev2', true]]),
2698
+ exec: async (cmd, attrs, opts) => {
2699
+ execCalled = true;
2700
+ if (opts && opts.untagged && opts.untagged.ENABLED) {
2701
+ await opts.untagged.ENABLED({ attributes: [{ value: 'IMAP4rev2' }] });
2702
+ }
2703
+ return { next: () => {} };
2704
+ }
2705
+ });
2706
+
2707
+ const result = await enableCommand(connection, ['IMAP4rev2']);
2708
+ test.equal(execCalled, true);
2709
+ test.ok(result.has('IMAP4REV2'));
2710
+ test.done();
2711
+ };
2712
+
2477
2713
  // ============================================
2478
2714
  // COMPRESS Command Tests
2479
2715
  // ============================================
@@ -3383,132 +3619,944 @@ module.exports['Commands: list handles error'] = async test => {
3383
3619
  }
3384
3620
  });
3385
3621
 
3386
- try {
3387
- await listCommand(connection, '', '*');
3388
- test.ok(false, 'Should have thrown');
3389
- } catch (err) {
3390
- test.equal(err.message, 'List failed');
3391
- }
3622
+ try {
3623
+ await listCommand(connection, '', '*');
3624
+ test.ok(false, 'Should have thrown');
3625
+ } catch (err) {
3626
+ test.equal(err.message, 'List failed');
3627
+ }
3628
+ test.done();
3629
+ };
3630
+
3631
+ module.exports['Commands: list handles empty attributes'] = async test => {
3632
+ const connection = createMockConnection({
3633
+ state: 3,
3634
+ exec: async (cmd, attrs, opts) => {
3635
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3636
+ // Empty attributes - should be skipped
3637
+ await opts.untagged.LIST({ attributes: [] });
3638
+ }
3639
+ return { next: () => {} };
3640
+ }
3641
+ });
3642
+
3643
+ const result = await listCommand(connection, '', '*');
3644
+ test.ok(Array.isArray(result));
3645
+ test.equal(result.length, 0);
3646
+ test.done();
3647
+ };
3648
+
3649
+ module.exports['Commands: list status fallback when LIST-STATUS not supported'] = async test => {
3650
+ let statusCalls = 0;
3651
+ const connection = createMockConnection({
3652
+ state: 3,
3653
+ capabilities: new Map(), // No LIST-STATUS
3654
+ // eslint-disable-next-line no-unused-vars
3655
+ run: async (cmd, path, query) => {
3656
+ if (cmd === 'STATUS') {
3657
+ statusCalls++;
3658
+ return { messages: 10, unseen: 5, path };
3659
+ }
3660
+ },
3661
+ exec: async (cmd, attrs, opts) => {
3662
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3663
+ await opts.untagged.LIST({
3664
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3665
+ });
3666
+ }
3667
+ return { next: () => {} };
3668
+ }
3669
+ });
3670
+
3671
+ const result = await listCommand(connection, '', '*', {
3672
+ statusQuery: { messages: true, unseen: true }
3673
+ });
3674
+ test.ok(Array.isArray(result));
3675
+ // STATUS should have been called for each folder
3676
+ test.equal(statusCalls, 1);
3677
+ test.done();
3678
+ };
3679
+
3680
+ module.exports['Commands: list handles STATUS errors gracefully'] = async test => {
3681
+ const connection = createMockConnection({
3682
+ state: 3,
3683
+ capabilities: new Map(),
3684
+ run: async cmd => {
3685
+ if (cmd === 'STATUS') {
3686
+ throw new Error('Status failed');
3687
+ }
3688
+ },
3689
+ exec: async (cmd, attrs, opts) => {
3690
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3691
+ await opts.untagged.LIST({
3692
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3693
+ });
3694
+ }
3695
+ return { next: () => {} };
3696
+ }
3697
+ });
3698
+
3699
+ const result = await listCommand(connection, '', '*', {
3700
+ statusQuery: { messages: true }
3701
+ });
3702
+ const inbox = result.find(e => e.path === 'INBOX');
3703
+ test.ok(inbox);
3704
+ // Status should have error property
3705
+ test.ok(inbox.status);
3706
+ test.ok(inbox.status.error);
3707
+ test.done();
3708
+ };
3709
+
3710
+ module.exports['Commands: list sorts by special use'] = async test => {
3711
+ const connection = createMockConnection({
3712
+ state: 3,
3713
+ capabilities: new Map([['SPECIAL-USE', true]]),
3714
+ exec: async (cmd, attrs, opts) => {
3715
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3716
+ // Add folders out of order
3717
+ await opts.untagged.LIST({
3718
+ attributes: [[{ value: '\\Trash' }], { value: '/' }, { value: 'Trash' }]
3719
+ });
3720
+ await opts.untagged.LIST({
3721
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3722
+ });
3723
+ await opts.untagged.LIST({
3724
+ attributes: [[{ value: '\\Sent' }], { value: '/' }, { value: 'Sent' }]
3725
+ });
3726
+ }
3727
+ return { next: () => {} };
3728
+ }
3729
+ });
3730
+
3731
+ const result = await listCommand(connection, '', '*');
3732
+ // INBOX should be first (has \\Inbox special use)
3733
+ test.equal(result[0].specialUse, '\\Inbox');
3734
+ test.done();
3735
+ };
3736
+
3737
+ module.exports['Commands: list handles delimiter in path'] = async test => {
3738
+ const connection = createMockConnection({
3739
+ state: 3,
3740
+ exec: async (cmd, attrs, opts) => {
3741
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3742
+ await opts.untagged.LIST({
3743
+ attributes: [
3744
+ [{ value: '\\HasNoChildren' }],
3745
+ { value: '/' },
3746
+ { value: '/Leading/Slash' } // Path starts with delimiter
3747
+ ]
3748
+ });
3749
+ }
3750
+ return { next: () => {} };
3751
+ }
3752
+ });
3753
+
3754
+ const result = await listCommand(connection, '', '*');
3755
+ const folder = result.find(e => e.name === 'Slash');
3756
+ test.ok(folder);
3757
+ // Leading delimiter should be removed
3758
+ test.equal(folder.path, 'Leading/Slash');
3759
+ test.done();
3760
+ };
3761
+
3762
+ module.exports['Commands: list skips Noselect folders for status'] = async test => {
3763
+ let statusCalls = 0;
3764
+ const connection = createMockConnection({
3765
+ state: 3,
3766
+ capabilities: new Map(),
3767
+ run: async cmd => {
3768
+ if (cmd === 'STATUS') {
3769
+ statusCalls++;
3770
+ return { messages: 10 };
3771
+ }
3772
+ },
3773
+ exec: async (cmd, attrs, opts) => {
3774
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3775
+ await opts.untagged.LIST({
3776
+ attributes: [[{ value: '\\Noselect' }], { value: '/' }, { value: 'Parent' }]
3777
+ });
3778
+ }
3779
+ return { next: () => {} };
3780
+ }
3781
+ });
3782
+
3783
+ await listCommand(connection, '', '*', { statusQuery: { messages: true } });
3784
+ // STATUS should not be called for Noselect folders
3785
+ test.equal(statusCalls, 0);
3786
+ test.done();
3787
+ };
3788
+
3789
+ module.exports['Commands: list adds Noselect to NonExistent mailboxes'] = async test => {
3790
+ const connection = createMockConnection({
3791
+ state: 3,
3792
+ exec: async (cmd, attrs, opts) => {
3793
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3794
+ await opts.untagged.LIST({
3795
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3796
+ });
3797
+ await opts.untagged.LIST({
3798
+ attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Phantom' }]
3799
+ });
3800
+ }
3801
+ return { next: () => {} };
3802
+ }
3803
+ });
3804
+
3805
+ const result = await listCommand(connection, '', '*');
3806
+ const phantom = result.find(e => e.path === 'Phantom');
3807
+ test.ok(phantom);
3808
+ // RFC 5258: \\NonExistent implies \\Noselect
3809
+ test.equal(phantom.flags.has('\\Noselect'), true);
3810
+ // The original flag is preserved, not replaced
3811
+ test.equal(phantom.flags.has('\\NonExistent'), true);
3812
+ const inbox = result.find(e => e.path === 'INBOX');
3813
+ test.ok(inbox);
3814
+ test.equal(inbox.flags.has('\\Noselect'), false);
3815
+ test.done();
3816
+ };
3817
+
3818
+ module.exports['Commands: list LSUB merge adds Noselect to NonExistent'] = async test => {
3819
+ const connection = createMockConnection({
3820
+ state: 3,
3821
+ exec: async (cmd, attrs, opts) => {
3822
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3823
+ await opts.untagged.LIST({
3824
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
3825
+ });
3826
+ }
3827
+ if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
3828
+ // Some servers only report \\NonExistent in LSUB responses
3829
+ await opts.untagged.LSUB({
3830
+ attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Folder1' }]
3831
+ });
3832
+ }
3833
+ return { next: () => {} };
3834
+ }
3835
+ });
3836
+
3837
+ const result = await listCommand(connection, '', '*');
3838
+ const folder = result.find(e => e.path === 'Folder1');
3839
+ test.ok(folder);
3840
+ test.equal(folder.subscribed, true);
3841
+ test.equal(folder.flags.has('\\NonExistent'), true);
3842
+ // RFC 5258: \\NonExistent merged from LSUB implies \\Noselect
3843
+ test.equal(folder.flags.has('\\Noselect'), true);
3844
+ test.done();
3845
+ };
3846
+
3847
+ module.exports['Commands: list uses RETURN (SUBSCRIBED) instead of LSUB on IMAP4rev2'] = async test => {
3848
+ let lsubCalled = false;
3849
+ let listAttrs = false;
3850
+ const connection = createMockConnection({
3851
+ state: 3,
3852
+ capabilities: new Map([['IMAP4rev2', true]]),
3853
+ exec: async (cmd, attrs, opts) => {
3854
+ if (cmd === 'LSUB') {
3855
+ lsubCalled = true;
3856
+ }
3857
+ if (cmd === 'LIST') {
3858
+ listAttrs = attrs;
3859
+ if (opts && opts.untagged && opts.untagged.LIST) {
3860
+ await opts.untagged.LIST({
3861
+ attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
3862
+ });
3863
+ await opts.untagged.LIST({
3864
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder2' }]
3865
+ });
3866
+ }
3867
+ }
3868
+ return { next: () => {} };
3869
+ }
3870
+ });
3871
+
3872
+ const result = await listCommand(connection, '', '*');
3873
+ // IMAP4rev2 removed LSUB, subscription state comes from RETURN (SUBSCRIBED)
3874
+ test.equal(lsubCalled, false);
3875
+ test.ok(JSON.stringify(listAttrs).includes('SUBSCRIBED'));
3876
+ const folder1 = result.find(e => e.path === 'Folder1');
3877
+ test.ok(folder1);
3878
+ test.equal(folder1.subscribed, true);
3879
+ // The \Subscribed attribute is folded into the subscribed property
3880
+ test.equal(folder1.flags.has('\\Subscribed'), false);
3881
+ const folder2 = result.find(e => e.path === 'Folder2');
3882
+ test.ok(folder2);
3883
+ test.ok(!folder2.subscribed);
3884
+ test.done();
3885
+ };
3886
+
3887
+ module.exports['Commands: list uses RETURN (SUBSCRIBED) with LIST-EXTENDED'] = async test => {
3888
+ let lsubCalled = false;
3889
+ let listAttrs = false;
3890
+ const connection = createMockConnection({
3891
+ state: 3,
3892
+ capabilities: new Map([
3893
+ ['IMAP4rev1', true],
3894
+ ['LIST-EXTENDED', true]
3895
+ ]),
3896
+ exec: async (cmd, attrs, opts) => {
3897
+ if (cmd === 'LSUB') {
3898
+ lsubCalled = true;
3899
+ }
3900
+ if (cmd === 'LIST') {
3901
+ listAttrs = attrs;
3902
+ if (opts && opts.untagged && opts.untagged.LIST) {
3903
+ await opts.untagged.LIST({
3904
+ attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3905
+ });
3906
+ }
3907
+ }
3908
+ return { next: () => {} };
3909
+ }
3910
+ });
3911
+
3912
+ const result = await listCommand(connection, '', '*');
3913
+ test.equal(lsubCalled, false);
3914
+ test.ok(JSON.stringify(listAttrs).includes('SUBSCRIBED'));
3915
+ const inbox = result.find(e => e.path === 'INBOX');
3916
+ test.ok(inbox);
3917
+ test.equal(inbox.subscribed, true);
3918
+ test.done();
3919
+ };
3920
+
3921
+ module.exports['Commands: list listOnly does not add RETURN args on IMAP4rev2'] = async test => {
3922
+ let listAttrs = false;
3923
+ const connection = createMockConnection({
3924
+ state: 3,
3925
+ capabilities: new Map([['IMAP4rev2', true]]),
3926
+ exec: async (cmd, attrs, opts) => {
3927
+ if (cmd === 'LIST') {
3928
+ listAttrs = attrs;
3929
+ if (opts && opts.untagged && opts.untagged.LIST) {
3930
+ await opts.untagged.LIST({
3931
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3932
+ });
3933
+ }
3934
+ }
3935
+ return { next: () => {} };
3936
+ }
3937
+ });
3938
+
3939
+ await listCommand(connection, '', '*', { listOnly: true });
3940
+ // Just reference and pattern, no RETURN block
3941
+ test.equal(listAttrs.length, 2);
3942
+ test.done();
3943
+ };
3944
+
3945
+ module.exports['Commands: list survives LSUB rejection'] = async test => {
3946
+ let lsubCalls = 0;
3947
+ const connection = createMockConnection({
3948
+ state: 3,
3949
+ exec: async (cmd, attrs, opts) => {
3950
+ if (cmd === 'LSUB') {
3951
+ lsubCalls++;
3952
+ // e.g. Exchange in IMAP4rev2 mode responds "BAD Command Argument Error"
3953
+ throw commandError('Command failed', 'BAD');
3954
+ }
3955
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3956
+ await opts.untagged.LIST({
3957
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3958
+ });
3959
+ await opts.untagged.LIST({
3960
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
3961
+ });
3962
+ }
3963
+ return { next: () => {} };
3964
+ }
3965
+ });
3966
+
3967
+ const result = await listCommand(connection, '', '*');
3968
+ test.equal(result.length, 2);
3969
+ const inbox = result.find(e => e.path === 'INBOX');
3970
+ test.ok(inbox);
3971
+ // INBOX is always reported as subscribed even without LSUB data
3972
+ test.equal(inbox.subscribed, true);
3973
+
3974
+ // The rejection is remembered - a follow-up listing skips LSUB entirely
3975
+ test.equal(connection.skipLsub, true);
3976
+ await listCommand(connection, '', '*');
3977
+ test.equal(lsubCalls, 1);
3978
+ test.done();
3979
+ };
3980
+
3981
+ module.exports['Commands: list fails when LSUB dies without a server rejection'] = async test => {
3982
+ const connection = createMockConnection({
3983
+ state: 3,
3984
+ exec: async (cmd, attrs, opts) => {
3985
+ if (cmd === 'LSUB') {
3986
+ // Transport failure, no tagged BAD/NO from the server
3987
+ throw commandError('Connection not available', null, 'NoConnection');
3988
+ }
3989
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3990
+ await opts.untagged.LIST({
3991
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3992
+ });
3993
+ }
3994
+ return { next: () => {} };
3995
+ }
3996
+ });
3997
+
3998
+ try {
3999
+ await listCommand(connection, '', '*');
4000
+ test.ok(false, 'Should have thrown');
4001
+ } catch (err) {
4002
+ test.equal(err.code, 'NoConnection');
4003
+ }
4004
+ // A transport failure says nothing about LSUB support - must not latch
4005
+ test.ok(!connection.skipLsub);
4006
+ test.done();
4007
+ };
4008
+
4009
+ module.exports['Commands: list rewrites a parsed error response into text'] = async test => {
4010
+ const connection = createMockConnection({
4011
+ state: 3,
4012
+ exec: async () => {
4013
+ // Same shape as the reader loop attaches for a tagged BAD
4014
+ let err = commandError('Command failed', 'BAD');
4015
+ err.response = { tag: '5', command: 'BAD', attributes: [{ type: 'TEXT', value: 'Command Argument Error. 12' }] };
4016
+ throw err;
4017
+ }
4018
+ });
4019
+
4020
+ try {
4021
+ await listCommand(connection, '', '*');
4022
+ test.ok(false, 'Should have thrown');
4023
+ } catch (err) {
4024
+ // enhanceCommandError folds the parsed object into a plain string
4025
+ test.equal(typeof err.response, 'string');
4026
+ test.ok(err.response.includes('Command Argument Error'));
4027
+ }
4028
+ test.done();
4029
+ };
4030
+
4031
+ module.exports['Commands: list retries with plain LIST when RETURN is rejected'] = async test => {
4032
+ let listCalls = 0;
4033
+ let lsubCalled = false;
4034
+ const connection = createMockConnection({
4035
+ state: 3,
4036
+ capabilities: new Map([
4037
+ ['IMAP4rev1', true],
4038
+ ['LIST-EXTENDED', true]
4039
+ ]),
4040
+ exec: async (cmd, attrs, opts) => {
4041
+ if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
4042
+ lsubCalled = true;
4043
+ await opts.untagged.LSUB({
4044
+ attributes: [[], { value: '/' }, { value: 'Folder1' }]
4045
+ });
4046
+ }
4047
+ if (cmd === 'LIST') {
4048
+ listCalls++;
4049
+ if (JSON.stringify(attrs).includes('SUBSCRIBED')) {
4050
+ // Partial untagged response arrives before the tagged BAD
4051
+ if (opts && opts.untagged && opts.untagged.LIST) {
4052
+ await opts.untagged.LIST({
4053
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4054
+ });
4055
+ }
4056
+ throw commandError('Command failed', 'BAD');
4057
+ }
4058
+ // Retry must be a plain LIST without RETURN args
4059
+ test.equal(attrs.length, 2);
4060
+ if (opts && opts.untagged && opts.untagged.LIST) {
4061
+ await opts.untagged.LIST({
4062
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4063
+ });
4064
+ }
4065
+ }
4066
+ return { next: () => {} };
4067
+ }
4068
+ });
4069
+
4070
+ const result = await listCommand(connection, '', '*');
4071
+ // Extended attempt, auxiliary-free attempt, plain attempt
4072
+ test.equal(listCalls, 3);
4073
+ test.equal(lsubCalled, true);
4074
+ // Partial rejected-attempt results were discarded - no duplicate entries
4075
+ test.equal(result.filter(e => e.path === 'Folder1').length, 1);
4076
+ const folder = result.find(e => e.path === 'Folder1');
4077
+ test.equal(folder.subscribed, true);
4078
+
4079
+ // The plain retry succeeding right after the rejection proves SUBSCRIBED was
4080
+ // the offending option - a follow-up listing goes straight to plain LIST
4081
+ test.equal(connection.skipListSubscribedArg, true);
4082
+ test.ok(!connection.skipListStatusArgs);
4083
+ await listCommand(connection, '', '*');
4084
+ test.equal(listCalls, 4);
4085
+ test.done();
4086
+ };
4087
+
4088
+ module.exports['Commands: list does not retry extended LIST on transport errors'] = async test => {
4089
+ let listCalls = 0;
4090
+ const connection = createMockConnection({
4091
+ state: 3,
4092
+ capabilities: new Map([['IMAP4rev2', true]]),
4093
+ exec: async cmd => {
4094
+ if (cmd === 'LIST') {
4095
+ listCalls++;
4096
+ // Dropped connection - no tagged BAD/NO from the server
4097
+ throw commandError('Connection not available', null, 'NoConnection');
4098
+ }
4099
+ return { next: () => {} };
4100
+ }
4101
+ });
4102
+
4103
+ try {
4104
+ await listCommand(connection, '', '*');
4105
+ test.ok(false, 'Should have thrown');
4106
+ } catch (err) {
4107
+ test.equal(err.code, 'NoConnection');
4108
+ }
4109
+ // A doomed retry against a dead connection is pointless
4110
+ test.equal(listCalls, 1);
4111
+ test.ok(!connection.skipListSubscribedArg);
4112
+ test.done();
4113
+ };
4114
+
4115
+ module.exports['Commands: list does not retry extended LIST on NO responses'] = async test => {
4116
+ let listCalls = 0;
4117
+ const connection = createMockConnection({
4118
+ state: 3,
4119
+ capabilities: new Map([['IMAP4rev2', true]]),
4120
+ exec: async cmd => {
4121
+ if (cmd === 'LIST') {
4122
+ listCalls++;
4123
+ // RFC 9051: unrecognized RETURN options are rejected with BAD;
4124
+ // NO is a transient operational failure
4125
+ throw commandError('Command failed', 'NO');
4126
+ }
4127
+ return { next: () => {} };
4128
+ }
4129
+ });
4130
+
4131
+ try {
4132
+ await listCommand(connection, '', '*');
4133
+ test.ok(false, 'Should have thrown');
4134
+ } catch (err) {
4135
+ test.equal(err.responseStatus, 'NO');
4136
+ }
4137
+ test.equal(listCalls, 1);
4138
+ test.ok(!connection.skipListSubscribedArg);
4139
+ test.done();
4140
+ };
4141
+
4142
+ module.exports['Commands: list does not treat throttling as a RETURN rejection'] = async test => {
4143
+ let listCalls = 0;
4144
+ const connection = createMockConnection({
4145
+ state: 3,
4146
+ capabilities: new Map([['IMAP4rev2', true]]),
4147
+ exec: async cmd => {
4148
+ if (cmd === 'LIST') {
4149
+ listCalls++;
4150
+ // O365-style throttling surfaces as BAD plus code ETHROTTLE
4151
+ throw commandError('Request is throttled', 'BAD', 'ETHROTTLE');
4152
+ }
4153
+ return { next: () => {} };
4154
+ }
4155
+ });
4156
+
4157
+ try {
4158
+ await listCommand(connection, '', '*');
4159
+ test.ok(false, 'Should have thrown');
4160
+ } catch (err) {
4161
+ test.equal(err.code, 'ETHROTTLE');
4162
+ }
4163
+ // Re-issuing against a throttled server and permanently downgrading the
4164
+ // connection would both be wrong
4165
+ test.equal(listCalls, 1);
4166
+ test.ok(!connection.skipListSubscribedArg);
4167
+ test.done();
4168
+ };
4169
+
4170
+ module.exports['Commands: list drops RETURN option groups one stage at a time'] = async test => {
4171
+ let listAttempts = [];
4172
+ let lsubCalled = false;
4173
+ const connection = createMockConnection({
4174
+ state: 3,
4175
+ capabilities: new Map([
4176
+ ['IMAP4rev1', true],
4177
+ ['LIST-EXTENDED', true],
4178
+ ['LIST-STATUS', true]
4179
+ ]),
4180
+ exec: async (cmd, attrs, opts) => {
4181
+ if (cmd === 'LSUB') {
4182
+ lsubCalled = true;
4183
+ }
4184
+ if (cmd === 'LIST') {
4185
+ let flat = JSON.stringify(attrs);
4186
+ listAttempts.push(flat);
4187
+ if (flat.includes('STATUS')) {
4188
+ throw commandError('Command failed', 'BAD');
4189
+ }
4190
+ if (opts && opts.untagged && opts.untagged.LIST) {
4191
+ await opts.untagged.LIST({
4192
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4193
+ });
4194
+ }
4195
+ }
4196
+ return { next: () => {} };
4197
+ }
4198
+ });
4199
+
4200
+ const result = await listCommand(connection, '', '*', { statusQuery: { messages: true } });
4201
+ test.equal(listAttempts.length, 4);
4202
+ // Stage 1: both option groups with the auxiliary options
4203
+ test.ok(listAttempts[0].includes('STATUS'));
4204
+ test.ok(listAttempts[0].includes('SUBSCRIBED'));
4205
+ test.ok(listAttempts[0].includes('CHILDREN'));
4206
+ // Stage 2: the same groups without the auxiliary options - the rejection
4207
+ // might have been about the auxiliaries alone
4208
+ test.ok(listAttempts[1].includes('STATUS'));
4209
+ test.ok(listAttempts[1].includes('SUBSCRIBED'));
4210
+ test.ok(!listAttempts[1].includes('CHILDREN'));
4211
+ // Stage 3: SUBSCRIBED dropped, STATUS kept
4212
+ test.ok(listAttempts[2].includes('STATUS'));
4213
+ test.ok(!listAttempts[2].includes('SUBSCRIBED'));
4214
+ // Stage 4: plain
4215
+ test.ok(!listAttempts[3].includes('RETURN'));
4216
+
4217
+ // Only the group whose removal was followed by success is latched - the BAD of
4218
+ // the earlier stages might have been caused by the STATUS group alone, so
4219
+ // SUBSCRIBED stays unproven and gets retried on the next listing
4220
+ test.equal(connection.skipListStatusArgs, true);
4221
+ test.ok(!connection.skipListSubscribedArg);
4222
+ test.ok(!connection.skipListAuxArgs);
4223
+ test.equal(lsubCalled, true);
4224
+ test.equal(result.length, 1);
4225
+
4226
+ // Next listing converges: SUBSCRIBED-only first, no STATUS args
4227
+ const result2 = await listCommand(connection, '', '*', { statusQuery: { messages: true } });
4228
+ test.equal(listAttempts.length, 5);
4229
+ test.ok(listAttempts[4].includes('SUBSCRIBED'));
4230
+ test.ok(!listAttempts[4].includes('STATUS'));
4231
+ test.equal(result2.length, 1);
4232
+ test.done();
4233
+ };
4234
+
4235
+ module.exports['Commands: list does not latch flags when the reduced retry also dies'] = async test => {
4236
+ let listCalls = 0;
4237
+ const connection = createMockConnection({
4238
+ state: 3,
4239
+ capabilities: new Map([
4240
+ ['IMAP4rev1', true],
4241
+ ['LIST-EXTENDED', true],
4242
+ ['LIST-STATUS', true]
4243
+ ]),
4244
+ exec: async cmd => {
4245
+ if (cmd === 'LIST') {
4246
+ listCalls++;
4247
+ if (listCalls === 1) {
4248
+ throw commandError('Command failed', 'BAD');
4249
+ }
4250
+ // The reduced retry dies on a transport error
4251
+ throw commandError('Connection not available', null, 'NoConnection');
4252
+ }
4253
+ return { next: () => {} };
4254
+ }
4255
+ });
4256
+
4257
+ try {
4258
+ await listCommand(connection, '', '*', { statusQuery: { messages: true } });
4259
+ test.ok(false, 'Should have thrown');
4260
+ } catch (err) {
4261
+ test.equal(err.code, 'NoConnection');
4262
+ }
4263
+ // Nothing was proven - no flag may be latched
4264
+ test.equal(listCalls, 2);
4265
+ test.ok(!connection.skipListSubscribedArg);
4266
+ test.ok(!connection.skipListStatusArgs);
4267
+ test.ok(!connection.skipListAuxArgs);
4268
+ test.done();
4269
+ };
4270
+
4271
+ module.exports['Commands: list tolerates LSUB NO without latching'] = async test => {
4272
+ let lsubCalls = 0;
4273
+ const connection = createMockConnection({
4274
+ state: 3,
4275
+ exec: async (cmd, attrs, opts) => {
4276
+ if (cmd === 'LSUB') {
4277
+ lsubCalls++;
4278
+ // Transient operational failure, not a missing command
4279
+ throw commandError('Server busy', 'NO');
4280
+ }
4281
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4282
+ await opts.untagged.LIST({
4283
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4284
+ });
4285
+ }
4286
+ return { next: () => {} };
4287
+ }
4288
+ });
4289
+
4290
+ const result = await listCommand(connection, '', '*');
4291
+ test.equal(result.length, 1);
4292
+ // NO is transient - the next listing must try LSUB again
4293
+ test.ok(!connection.skipLsub);
4294
+ await listCommand(connection, '', '*');
4295
+ test.equal(lsubCalls, 2);
4296
+ test.done();
4297
+ };
4298
+
4299
+ module.exports['Commands: list rethrows throttled LSUB without latching'] = async test => {
4300
+ const connection = createMockConnection({
4301
+ state: 3,
4302
+ exec: async (cmd, attrs, opts) => {
4303
+ if (cmd === 'LSUB') {
4304
+ throw commandError('Request is throttled', 'BAD', 'ETHROTTLE');
4305
+ }
4306
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4307
+ await opts.untagged.LIST({
4308
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4309
+ });
4310
+ }
4311
+ return { next: () => {} };
4312
+ }
4313
+ });
4314
+
4315
+ try {
4316
+ await listCommand(connection, '', '*');
4317
+ test.ok(false, 'Should have thrown');
4318
+ } catch (err) {
4319
+ test.equal(err.code, 'ETHROTTLE');
4320
+ }
4321
+ // Throttling says nothing about LSUB support
4322
+ test.ok(!connection.skipLsub);
4323
+ test.done();
4324
+ };
4325
+
4326
+ module.exports['Commands: list folds LSUB-delivered Subscribed flag into the property'] = async test => {
4327
+ const connection = createMockConnection({
4328
+ state: 3,
4329
+ exec: async (cmd, attrs, opts) => {
4330
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4331
+ await opts.untagged.LIST({
4332
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4333
+ });
4334
+ }
4335
+ if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
4336
+ // Some servers echo the RFC 5258 \Subscribed attribute in LSUB
4337
+ await opts.untagged.LSUB({
4338
+ attributes: [[{ value: '\\Subscribed' }], { value: '/' }, { value: 'Folder1' }]
4339
+ });
4340
+ }
4341
+ return { next: () => {} };
4342
+ }
4343
+ });
4344
+
4345
+ const result = await listCommand(connection, '', '*');
4346
+ const folder = result.find(e => e.path === 'Folder1');
4347
+ test.equal(folder.subscribed, true);
4348
+ // The flag is folded into the property on the LSUB merge path too
4349
+ test.equal(folder.flags.has('\\Subscribed'), false);
4350
+ test.done();
4351
+ };
4352
+
4353
+ module.exports['Commands: list retries INBOX fixup plain without latching'] = async test => {
4354
+ let listAttempts = [];
4355
+ const connection = createMockConnection({
4356
+ state: 3,
4357
+ capabilities: new Map([
4358
+ ['IMAP4rev1', true],
4359
+ ['LIST-EXTENDED', true]
4360
+ ]),
4361
+ exec: async (cmd, attrs, opts) => {
4362
+ if (cmd === 'LIST') {
4363
+ let flat = JSON.stringify(attrs);
4364
+ listAttempts.push(flat);
4365
+ if (listAttempts.length === 2) {
4366
+ // Fixup call with RETURN args is rejected by a quirky server
4367
+ throw commandError('Command failed', 'BAD');
4368
+ }
4369
+ if (opts && opts.untagged && opts.untagged.LIST) {
4370
+ if (listAttempts.length === 1) {
4371
+ await opts.untagged.LIST({
4372
+ attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '.' }, { value: 'Prefix.Folder1' }]
4373
+ });
4374
+ } else {
4375
+ await opts.untagged.LIST({
4376
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '.' }, { value: 'INBOX' }]
4377
+ });
4378
+ }
4379
+ }
4380
+ }
4381
+ return { next: () => {} };
4382
+ }
4383
+ });
4384
+
4385
+ const result = await listCommand(connection, 'Prefix.', '*');
4386
+ // Main listing + rejected fixup + plain fixup retry
4387
+ test.equal(listAttempts.length, 3);
4388
+ test.ok(listAttempts[1].includes('SUBSCRIBED'));
4389
+ test.ok(!listAttempts[2].includes('RETURN'));
4390
+ // Entries from the successful main run were kept
4391
+ test.ok(result.find(e => e.path === 'Prefix.Folder1'));
4392
+ test.ok(result.find(e => e.path === 'INBOX'));
4393
+ // The main run succeeded with the same RETURN args - nothing may be latched
4394
+ test.ok(!connection.skipListSubscribedArg);
4395
+ test.ok(!connection.skipListStatusArgs);
3392
4396
  test.done();
3393
4397
  };
3394
4398
 
3395
- module.exports['Commands: list handles empty attributes'] = async test => {
4399
+ module.exports['Commands: list discards partial results from a rejected INBOX fixup'] = async test => {
4400
+ let listAttempts = [];
3396
4401
  const connection = createMockConnection({
3397
4402
  state: 3,
4403
+ capabilities: new Map([
4404
+ ['IMAP4rev1', true],
4405
+ ['LIST-EXTENDED', true]
4406
+ ]),
3398
4407
  exec: async (cmd, attrs, opts) => {
3399
- if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3400
- // Empty attributes - should be skipped
3401
- await opts.untagged.LIST({ attributes: [] });
4408
+ if (cmd === 'LIST') {
4409
+ listAttempts.push(JSON.stringify(attrs));
4410
+ if (listAttempts.length === 1) {
4411
+ await opts.untagged.LIST({
4412
+ attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '.' }, { value: 'Prefix.Folder1' }]
4413
+ });
4414
+ } else if (listAttempts.length === 2) {
4415
+ // The fixup attempt streams an untagged INBOX line and THEN gets
4416
+ // the tagged BAD - the partial line must not survive the retry
4417
+ await opts.untagged.LIST({
4418
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '.' }, { value: 'INBOX' }]
4419
+ });
4420
+ throw commandError('Command failed', 'BAD');
4421
+ } else {
4422
+ await opts.untagged.LIST({
4423
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '.' }, { value: 'INBOX' }]
4424
+ });
4425
+ }
3402
4426
  }
3403
4427
  return { next: () => {} };
3404
4428
  }
3405
4429
  });
3406
4430
 
3407
- const result = await listCommand(connection, '', '*');
3408
- test.ok(Array.isArray(result));
3409
- test.equal(result.length, 0);
4431
+ const result = await listCommand(connection, 'Prefix.', '*');
4432
+ test.equal(listAttempts.length, 3);
4433
+ // Exactly one INBOX entry - the rejected attempt's partial line was discarded
4434
+ test.equal(result.filter(e => e.path === 'INBOX').length, 1);
4435
+ test.ok(result.find(e => e.path === 'Prefix.Folder1'));
3410
4436
  test.done();
3411
4437
  };
3412
4438
 
3413
- module.exports['Commands: list status fallback when LIST-STATUS not supported'] = async test => {
3414
- let statusCalls = 0;
4439
+ module.exports['Commands: list latches only the auxiliary options when the server rejects them'] = async test => {
4440
+ let listAttempts = [];
3415
4441
  const connection = createMockConnection({
3416
4442
  state: 3,
3417
- capabilities: new Map(), // No LIST-STATUS
3418
- // eslint-disable-next-line no-unused-vars
3419
- run: async (cmd, path, query) => {
3420
- if (cmd === 'STATUS') {
3421
- statusCalls++;
3422
- return { messages: 10, unseen: 5, path };
3423
- }
3424
- },
4443
+ capabilities: new Map([
4444
+ ['IMAP4rev1', true],
4445
+ ['LIST-EXTENDED', true],
4446
+ ['LIST-STATUS', true],
4447
+ ['SPECIAL-USE', true]
4448
+ ]),
3425
4449
  exec: async (cmd, attrs, opts) => {
3426
- if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4450
+ if (cmd === 'LIST') {
4451
+ let flat = JSON.stringify(attrs);
4452
+ listAttempts.push(flat);
4453
+ if (flat.includes('SPECIAL-USE') || flat.includes('CHILDREN')) {
4454
+ throw commandError('Command failed', 'BAD');
4455
+ }
3427
4456
  await opts.untagged.LIST({
3428
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4457
+ attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
3429
4458
  });
4459
+ if (opts.untagged.STATUS) {
4460
+ await opts.untagged.STATUS({
4461
+ attributes: [{ value: 'Folder1' }, [{ value: 'MESSAGES' }, { value: '3' }]]
4462
+ });
4463
+ }
3430
4464
  }
3431
4465
  return { next: () => {} };
3432
4466
  }
3433
4467
  });
3434
4468
 
3435
- const result = await listCommand(connection, '', '*', {
3436
- statusQuery: { messages: true, unseen: true }
3437
- });
3438
- test.ok(Array.isArray(result));
3439
- // STATUS should have been called for each folder
3440
- test.equal(statusCalls, 1);
4469
+ const result = await listCommand(connection, '', '*', { statusQuery: { messages: true } });
4470
+ // Extended attempt with auxiliaries, then the same groups without them
4471
+ test.equal(listAttempts.length, 2);
4472
+ test.ok(listAttempts[0].includes('SPECIAL-USE'));
4473
+ test.ok(listAttempts[1].includes('STATUS'));
4474
+ test.ok(listAttempts[1].includes('SUBSCRIBED'));
4475
+ test.ok(!listAttempts[1].includes('SPECIAL-USE'));
4476
+ test.ok(!listAttempts[1].includes('CHILDREN'));
4477
+ // Only the auxiliaries are latched - both option groups survived intact
4478
+ test.equal(connection.skipListAuxArgs, true);
4479
+ test.ok(!connection.skipListSubscribedArg);
4480
+ test.ok(!connection.skipListStatusArgs);
4481
+ let folder = result.find(e => e.path === 'Folder1');
4482
+ test.equal(folder.subscribed, true);
4483
+ test.equal(folder.status.messages, 3);
4484
+
4485
+ // The next listing goes straight to the auxiliary-free extended form
4486
+ await listCommand(connection, '', '*', { statusQuery: { messages: true } });
4487
+ test.equal(listAttempts.length, 3);
4488
+ test.ok(listAttempts[2].includes('STATUS'));
4489
+ test.ok(listAttempts[2].includes('SUBSCRIBED'));
4490
+ test.ok(!listAttempts[2].includes('SPECIAL-USE'));
3441
4491
  test.done();
3442
4492
  };
3443
4493
 
3444
- module.exports['Commands: list handles STATUS errors gracefully'] = async test => {
4494
+ module.exports['Commands: list falls back to LSUB when RETURN (SUBSCRIBED) is silently ignored'] = async test => {
4495
+ let lsubCalled = false;
3445
4496
  const connection = createMockConnection({
3446
4497
  state: 3,
3447
- capabilities: new Map(),
3448
- run: async cmd => {
3449
- if (cmd === 'STATUS') {
3450
- throw new Error('Status failed');
3451
- }
3452
- },
4498
+ capabilities: new Map([
4499
+ ['IMAP4rev1', true],
4500
+ ['LIST-EXTENDED', true]
4501
+ ]),
3453
4502
  exec: async (cmd, attrs, opts) => {
4503
+ if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
4504
+ lsubCalled = true;
4505
+ await opts.untagged.LSUB({
4506
+ attributes: [[], { value: '/' }, { value: 'Folder1' }]
4507
+ });
4508
+ }
3454
4509
  if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4510
+ // Server accepted RETURN (SUBSCRIBED) but returned no \Subscribed flags
3455
4511
  await opts.untagged.LIST({
3456
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4512
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
3457
4513
  });
3458
4514
  }
3459
4515
  return { next: () => {} };
3460
4516
  }
3461
4517
  });
3462
4518
 
3463
- const result = await listCommand(connection, '', '*', {
3464
- statusQuery: { messages: true }
3465
- });
3466
- const inbox = result.find(e => e.path === 'INBOX');
3467
- test.ok(inbox);
3468
- // Status should have error property
3469
- test.ok(inbox.status);
3470
- test.ok(inbox.status.error);
4519
+ const result = await listCommand(connection, '', '*');
4520
+ test.equal(lsubCalled, true);
4521
+ test.equal(result.find(e => e.path === 'Folder1').subscribed, true);
3471
4522
  test.done();
3472
4523
  };
3473
4524
 
3474
- module.exports['Commands: list sorts by special use'] = async test => {
4525
+ module.exports['Commands: list skips the LSUB safety net on rev2 sessions'] = async test => {
4526
+ let lsubCalled = false;
3475
4527
  const connection = createMockConnection({
3476
4528
  state: 3,
3477
- capabilities: new Map([['SPECIAL-USE', true]]),
4529
+ capabilities: new Map([['IMAP4rev2', true]]),
3478
4530
  exec: async (cmd, attrs, opts) => {
4531
+ if (cmd === 'LSUB') {
4532
+ lsubCalled = true;
4533
+ }
3479
4534
  if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3480
- // Add folders out of order
3481
- await opts.untagged.LIST({
3482
- attributes: [[{ value: '\\Trash' }], { value: '/' }, { value: 'Trash' }]
3483
- });
3484
- await opts.untagged.LIST({
3485
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3486
- });
4535
+ // No folder is subscribed - legitimate on a fresh account, and rev2
4536
+ // removed LSUB so there is nothing to fall back to
3487
4537
  await opts.untagged.LIST({
3488
- attributes: [[{ value: '\\Sent' }], { value: '/' }, { value: 'Sent' }]
4538
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
3489
4539
  });
3490
4540
  }
3491
4541
  return { next: () => {} };
3492
4542
  }
3493
4543
  });
3494
4544
 
3495
- const result = await listCommand(connection, '', '*');
3496
- // INBOX should be first (has \\Inbox special use)
3497
- test.equal(result[0].specialUse, '\\Inbox');
4545
+ await listCommand(connection, '', '*');
4546
+ test.equal(lsubCalled, false);
3498
4547
  test.done();
3499
4548
  };
3500
4549
 
3501
- module.exports['Commands: list handles delimiter in path'] = async test => {
4550
+ module.exports['Commands: list honors special-use flags on rev2-only servers'] = async test => {
3502
4551
  const connection = createMockConnection({
3503
4552
  state: 3,
4553
+ capabilities: new Map([['IMAP4rev2', true]]),
3504
4554
  exec: async (cmd, attrs, opts) => {
3505
4555
  if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4556
+ // RFC 9051 folds the RFC 6154 attributes into base rev2 - no separate
4557
+ // SPECIAL-USE capability token is required
3506
4558
  await opts.untagged.LIST({
3507
- attributes: [
3508
- [{ value: '\\HasNoChildren' }],
3509
- { value: '/' },
3510
- { value: '/Leading/Slash' } // Path starts with delimiter
3511
- ]
4559
+ attributes: [[{ value: '\\Sent' }, { value: '\\HasNoChildren' }], { value: '/' }, { value: 'Custom-Sent-Name' }]
3512
4560
  });
3513
4561
  }
3514
4562
  return { next: () => {} };
@@ -3516,95 +4564,80 @@ module.exports['Commands: list handles delimiter in path'] = async test => {
3516
4564
  });
3517
4565
 
3518
4566
  const result = await listCommand(connection, '', '*');
3519
- const folder = result.find(e => e.name === 'Slash');
3520
- test.ok(folder);
3521
- // Leading delimiter should be removed
3522
- test.equal(folder.path, 'Leading/Slash');
4567
+ const folder = result.find(e => e.path === 'Custom-Sent-Name');
4568
+ test.equal(folder.specialUse, '\\Sent');
3523
4569
  test.done();
3524
4570
  };
3525
4571
 
3526
- module.exports['Commands: list skips Noselect folders for status'] = async test => {
3527
- let statusCalls = 0;
4572
+ module.exports['Commands: list uses inline STATUS on rev2-only servers and omits RECENT'] = async test => {
4573
+ let listAttrs = false;
4574
+ let statusCommands = 0;
3528
4575
  const connection = createMockConnection({
3529
4576
  state: 3,
3530
- capabilities: new Map(),
4577
+ capabilities: new Map([['IMAP4rev2', true]]),
3531
4578
  run: async cmd => {
3532
4579
  if (cmd === 'STATUS') {
3533
- statusCalls++;
3534
- return { messages: 10 };
4580
+ statusCommands++;
3535
4581
  }
4582
+ return {};
3536
4583
  },
3537
4584
  exec: async (cmd, attrs, opts) => {
3538
- if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3539
- await opts.untagged.LIST({
3540
- attributes: [[{ value: '\\Noselect' }], { value: '/' }, { value: 'Parent' }]
3541
- });
4585
+ if (cmd === 'LIST') {
4586
+ listAttrs = JSON.stringify(attrs);
4587
+ if (opts && opts.untagged && opts.untagged.LIST) {
4588
+ await opts.untagged.LIST({
4589
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4590
+ });
4591
+ }
4592
+ if (opts && opts.untagged && opts.untagged.STATUS) {
4593
+ await opts.untagged.STATUS({
4594
+ attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '5' }]]
4595
+ });
4596
+ }
3542
4597
  }
3543
4598
  return { next: () => {} };
3544
4599
  }
3545
4600
  });
3546
4601
 
3547
- await listCommand(connection, '', '*', { statusQuery: { messages: true } });
3548
- // STATUS should not be called for Noselect folders
3549
- test.equal(statusCalls, 0);
4602
+ const result = await listCommand(connection, '', '*', { statusQuery: { messages: true, recent: true } });
4603
+ // LIST-STATUS is part of base rev2, so STATUS data arrives inline
4604
+ test.ok(listAttrs.includes('STATUS'));
4605
+ // RECENT was removed in rev2 and must not be requested
4606
+ test.ok(!listAttrs.includes('RECENT'));
4607
+ test.equal(statusCommands, 0);
4608
+ test.equal(result.find(e => e.path === 'INBOX').status.messages, 5);
4609
+ // The requested recent value is synthesized - rev2 defines it as always 0
4610
+ test.equal(result.find(e => e.path === 'INBOX').status.recent, 0);
3550
4611
  test.done();
3551
4612
  };
3552
4613
 
3553
- module.exports['Commands: list adds Noselect to NonExistent mailboxes'] = async test => {
4614
+ module.exports['Commands: list does not let NonExistent phantoms win special-use by name'] = async test => {
3554
4615
  const connection = createMockConnection({
3555
4616
  state: 3,
3556
4617
  exec: async (cmd, attrs, opts) => {
3557
4618
  if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4619
+ // Phantom subscription leftover of a deleted folder
3558
4620
  await opts.untagged.LIST({
3559
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3560
- });
3561
- await opts.untagged.LIST({
3562
- attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Phantom' }]
4621
+ attributes: [[{ value: '\\NonExistent' }, { value: '\\Subscribed' }], { value: '/' }, { value: 'Sent' }]
3563
4622
  });
3564
- }
3565
- return { next: () => {} };
3566
- }
3567
- });
3568
-
3569
- const result = await listCommand(connection, '', '*');
3570
- const phantom = result.find(e => e.path === 'Phantom');
3571
- test.ok(phantom);
3572
- // RFC 5258: \\NonExistent implies \\Noselect
3573
- test.equal(phantom.flags.has('\\Noselect'), true);
3574
- // The original flag is preserved, not replaced
3575
- test.equal(phantom.flags.has('\\NonExistent'), true);
3576
- const inbox = result.find(e => e.path === 'INBOX');
3577
- test.ok(inbox);
3578
- test.equal(inbox.flags.has('\\Noselect'), false);
3579
- test.done();
3580
- };
3581
-
3582
- module.exports['Commands: list LSUB merge adds Noselect to NonExistent'] = async test => {
3583
- const connection = createMockConnection({
3584
- state: 3,
3585
- exec: async (cmd, attrs, opts) => {
3586
- if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4623
+ // The real sent-mail folder, matched by name
3587
4624
  await opts.untagged.LIST({
3588
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4625
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Sent Messages' }]
3589
4626
  });
3590
4627
  }
3591
- if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
3592
- // Some servers only report \\NonExistent in LSUB responses
3593
- await opts.untagged.LSUB({
3594
- attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Folder1' }]
3595
- });
4628
+ if (cmd === 'LSUB') {
4629
+ throw commandError('Command failed', 'BAD');
3596
4630
  }
3597
4631
  return { next: () => {} };
3598
4632
  }
3599
4633
  });
3600
4634
 
3601
4635
  const result = await listCommand(connection, '', '*');
3602
- const folder = result.find(e => e.path === 'Folder1');
3603
- test.ok(folder);
3604
- test.equal(folder.subscribed, true);
3605
- test.equal(folder.flags.has('\\NonExistent'), true);
3606
- // RFC 5258: \\NonExistent merged from LSUB implies \\Noselect
3607
- test.equal(folder.flags.has('\\Noselect'), true);
4636
+ const phantom = result.find(e => e.path === 'Sent');
4637
+ const real = result.find(e => e.path === 'Sent Messages');
4638
+ test.ok(phantom);
4639
+ test.notEqual(phantom.specialUse, '\\Sent');
4640
+ test.equal(real.specialUse, '\\Sent');
3608
4641
  test.done();
3609
4642
  };
3610
4643
 
@@ -4667,6 +5700,48 @@ module.exports['Commands: status skips when no query attributes'] = async test =
4667
5700
  test.done();
4668
5701
  };
4669
5702
 
5703
+ module.exports['Commands: status returns synthetic recent on rev2 sessions'] = async test => {
5704
+ let execCalled = false;
5705
+ const connection = createMockConnection({
5706
+ state: 2,
5707
+ capabilities: new Map([['IMAP4rev2', true]]),
5708
+ exec: async () => {
5709
+ execCalled = true;
5710
+ return { next: () => {} };
5711
+ }
5712
+ });
5713
+
5714
+ // RECENT does not exist in IMAP4rev2 - the caller still gets a status object
5715
+ // (recent is 0 by definition) instead of false, and no command is sent
5716
+ const result = await statusCommand(connection, 'INBOX', { recent: true });
5717
+ test.equal(execCalled, false);
5718
+ test.deepEqual(result, { path: 'INBOX', recent: 0 });
5719
+ test.done();
5720
+ };
5721
+
5722
+ module.exports['Commands: status merges synthetic recent into rev2 query results'] = async test => {
5723
+ let queryAttrs = null;
5724
+ const connection = createMockConnection({
5725
+ state: 2,
5726
+ capabilities: new Map([['IMAP4rev2', true]]),
5727
+ exec: async (cmd, attrs, opts) => {
5728
+ queryAttrs = JSON.stringify(attrs);
5729
+ await opts.untagged.STATUS({
5730
+ attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '100' }]]
5731
+ });
5732
+ return { next: () => {} };
5733
+ }
5734
+ });
5735
+
5736
+ const result = await statusCommand(connection, 'INBOX', { messages: true, recent: true });
5737
+ // RECENT must not be requested from a rev2 session, but the result keeps the
5738
+ // rev1 shape for the same query
5739
+ test.ok(!queryAttrs.includes('RECENT'));
5740
+ test.equal(result.messages, 100);
5741
+ test.equal(result.recent, 0);
5742
+ test.done();
5743
+ };
5744
+
4670
5745
  module.exports['Commands: status skips when all query values are false'] = async test => {
4671
5746
  const connection = createMockConnection({ state: 2 });
4672
5747
 
@@ -5766,6 +6841,27 @@ module.exports['Commands: idle with IDLE capability'] = async test => {
5766
6841
  test.done();
5767
6842
  };
5768
6843
 
6844
+ module.exports['Commands: idle uses IDLE on rev2-only servers without the IDLE token'] = async test => {
6845
+ let execCommand = '';
6846
+ const connection = createMockConnection({
6847
+ state: 3,
6848
+ // IDLE is part of base IMAP4rev2 - no separate token required
6849
+ capabilities: new Map([['IMAP4rev2', true]]),
6850
+ exec: async (cmd, attrs, opts) => {
6851
+ execCommand = cmd;
6852
+ if (opts && opts.onPlusTag) {
6853
+ await opts.onPlusTag();
6854
+ }
6855
+ return { next: () => {} };
6856
+ },
6857
+ write: () => {}
6858
+ });
6859
+
6860
+ await idleCommand(connection);
6861
+ test.equal(execCommand, 'IDLE');
6862
+ test.done();
6863
+ };
6864
+
5769
6865
  module.exports['Commands: idle skips when not selected'] = async test => {
5770
6866
  const connection = createMockConnection({ state: 2 }); // AUTHENTICATED
5771
6867
 
@@ -7441,6 +8537,55 @@ module.exports['Commands: authenticate with OAUTHBEARER'] = async test => {
7441
8537
  test.equal(execArgs.cmd, 'AUTHENTICATE');
7442
8538
  test.equal(execArgs.args[0].value, 'OAUTHBEARER');
7443
8539
  test.ok(connection.authCapabilities.has('AUTH=OAUTHBEARER'));
8540
+ test.ok(decodeSaslPayload(execArgs).includes('port=993'), 'the payload should report the connection port');
8541
+ test.done();
8542
+ };
8543
+
8544
+ module.exports['Commands: OAUTHBEARER payload reports the port actually in use'] = async test => {
8545
+ // Regression: the port field was hardcoded to 993 - see lib/commands/authenticate.js.
8546
+ let execArgs = null;
8547
+ const connection = createMockConnection({
8548
+ state: 1,
8549
+ capabilities: new Map([['AUTH=OAUTHBEARER', true]]),
8550
+ servername: 'imap.example.com',
8551
+ port: 143,
8552
+ authCapabilities: new Map(),
8553
+ exec: async (cmd, args) => {
8554
+ execArgs = { cmd, args };
8555
+ return { next: () => {} };
8556
+ },
8557
+ write: () => {}
8558
+ });
8559
+
8560
+ await authenticateCommand(connection, 'user@example.com', { accessToken: 'token123' });
8561
+
8562
+ const payload = decodeSaslPayload(execArgs);
8563
+ test.ok(payload.includes('port=143'), `expected port=143 in the SASL payload, got: ${JSON.stringify(payload)}`);
8564
+ test.ok(payload.includes('host=imap.example.com'), 'the host field should still describe the connection');
8565
+ test.done();
8566
+ };
8567
+
8568
+ module.exports['Commands: OAUTHBEARER payload falls back to host when servername is false'] = async test => {
8569
+ // imap-flow.js sets servername = false for a bare-IP host, which rendered as "host=false".
8570
+ let execArgs = null;
8571
+ const connection = createMockConnection({
8572
+ state: 1,
8573
+ capabilities: new Map([['AUTH=OAUTHBEARER', true]]),
8574
+ servername: false,
8575
+ host: '198.51.100.7',
8576
+ authCapabilities: new Map(),
8577
+ exec: async (cmd, args) => {
8578
+ execArgs = { cmd, args };
8579
+ return { next: () => {} };
8580
+ },
8581
+ write: () => {}
8582
+ });
8583
+
8584
+ await authenticateCommand(connection, 'user@example.com', { accessToken: 'token123' });
8585
+
8586
+ const payload = decodeSaslPayload(execArgs);
8587
+ test.ok(payload.includes('host=198.51.100.7'), `expected the host as fallback, got: ${JSON.stringify(payload)}`);
8588
+ test.ok(!payload.includes('host=false'), 'the literal "host=false" must never be sent');
7444
8589
  test.done();
7445
8590
  };
7446
8591
 
@@ -8553,10 +9698,11 @@ module.exports['Commands: enable updates connection.enabled'] = async test => {
8553
9698
  });
8554
9699
 
8555
9700
  await enableCommand(connection, ['CONDSTORE', 'UTF8=ACCEPT']);
8556
- // connection.enabled should be replaced with new set
9701
+ // New grants are merged in; earlier grants survive because the untagged
9702
+ // ENABLED response only lists extensions enabled by this command (RFC 5161)
8557
9703
  test.ok(connection.enabled.has('CONDSTORE'));
8558
9704
  test.ok(connection.enabled.has('UTF8=ACCEPT'));
8559
- test.ok(!connection.enabled.has('EXISTING')); // Old value should be gone
9705
+ test.ok(connection.enabled.has('EXISTING'));
8560
9706
  test.done();
8561
9707
  };
8562
9708