imapflow 1.4.8 → 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.
@@ -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,109 @@ 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 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
+
445
566
  module.exports['Commands: search with UID option'] = async test => {
446
567
  let execCmd = null;
447
568
  const connection = createMockConnection({
@@ -1208,6 +1329,23 @@ module.exports['Commands: move with UID and MOVE capability'] = async test => {
1208
1329
  test.done();
1209
1330
  };
1210
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
+
1211
1349
  module.exports['Commands: move skips when not selected'] = async test => {
1212
1350
  const connection = createMockConnection({ state: 2 });
1213
1351
 
@@ -1561,6 +1699,25 @@ module.exports['Commands: expunge with UID range'] = async test => {
1561
1699
  test.done();
1562
1700
  };
1563
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
+
1564
1721
  module.exports['Commands: expunge skips when not selected'] = async test => {
1565
1722
  const connection = createMockConnection({ state: 2 });
1566
1723
 
@@ -2480,6 +2637,79 @@ module.exports['Commands: enable handles error'] = async test => {
2480
2637
  test.done();
2481
2638
  };
2482
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
+
2483
2713
  // ============================================
2484
2714
  // COMPRESS Command Tests
2485
2715
  // ============================================
@@ -3614,109 +3844,120 @@ module.exports['Commands: list LSUB merge adds Noselect to NonExistent'] = async
3614
3844
  test.done();
3615
3845
  };
3616
3846
 
3617
- module.exports['Commands: list does not STATUS NonExistent mailboxes'] = async test => {
3618
- let statusPaths = [];
3847
+ module.exports['Commands: list uses RETURN (SUBSCRIBED) instead of LSUB on IMAP4rev2'] = async test => {
3848
+ let lsubCalled = false;
3849
+ let listAttrs = false;
3619
3850
  const connection = createMockConnection({
3620
3851
  state: 3,
3621
- capabilities: new Map(),
3622
- run: async (cmd, path) => {
3623
- if (cmd === 'STATUS') {
3624
- statusPaths.push(path);
3625
- return { messages: 10 };
3626
- }
3627
- },
3852
+ capabilities: new Map([['IMAP4rev2', true]]),
3628
3853
  exec: async (cmd, attrs, opts) => {
3629
- if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3630
- await opts.untagged.LIST({
3631
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3632
- });
3633
- await opts.untagged.LIST({
3634
- attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Phantom' }]
3635
- });
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
+ }
3636
3867
  }
3637
3868
  return { next: () => {} };
3638
3869
  }
3639
3870
  });
3640
3871
 
3641
- await listCommand(connection, '', '*', { statusQuery: { messages: true } });
3642
- // STATUS runs for the selectable mailbox only
3643
- test.deepEqual(statusPaths, ['INBOX']);
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);
3644
3884
  test.done();
3645
3885
  };
3646
3886
 
3647
- module.exports['Commands: list XLIST removes Inbox flag from non-INBOX'] = async test => {
3887
+ module.exports['Commands: list uses RETURN (SUBSCRIBED) with LIST-EXTENDED'] = async test => {
3888
+ let lsubCalled = false;
3889
+ let listAttrs = false;
3648
3890
  const connection = createMockConnection({
3649
3891
  state: 3,
3650
- capabilities: new Map([['XLIST', true]]),
3892
+ capabilities: new Map([
3893
+ ['IMAP4rev1', true],
3894
+ ['LIST-EXTENDED', true]
3895
+ ]),
3651
3896
  exec: async (cmd, attrs, opts) => {
3652
- if (cmd === 'XLIST' && opts && opts.untagged && opts.untagged.XLIST) {
3653
- // XLIST may have localised inbox name with \\Inbox flag
3654
- await opts.untagged.XLIST({
3655
- attributes: [
3656
- [{ value: '\\Inbox' }, { value: '\\HasNoChildren' }],
3657
- { value: '/' },
3658
- { value: 'Posteingang' } // German for inbox
3659
- ]
3660
- });
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
+ }
3661
3907
  }
3662
3908
  return { next: () => {} };
3663
3909
  }
3664
3910
  });
3665
3911
 
3666
3912
  const result = await listCommand(connection, '', '*');
3667
- const folder = result.find(e => e.path === 'Posteingang');
3668
- test.ok(folder);
3669
- // \\Inbox flag should be removed from flags set
3670
- test.equal(folder.flags.has('\\Inbox'), false);
3671
- // But it should have \\Inbox special use
3672
- test.equal(folder.specialUse, '\\Inbox');
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);
3673
3918
  test.done();
3674
3919
  };
3675
3920
 
3676
- module.exports['Commands: list LSUB path with leading delimiter'] = async test => {
3921
+ module.exports['Commands: list listOnly does not add RETURN args on IMAP4rev2'] = async test => {
3922
+ let listAttrs = false;
3677
3923
  const connection = createMockConnection({
3678
3924
  state: 3,
3679
- capabilities: new Map([['SPECIAL-USE', true]]),
3925
+ capabilities: new Map([['IMAP4rev2', true]]),
3680
3926
  exec: async (cmd, attrs, opts) => {
3681
- if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3682
- await opts.untagged.LIST({
3683
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
3684
- });
3685
- }
3686
- if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
3687
- // LSUB returns path with leading delimiter
3688
- await opts.untagged.LSUB({
3689
- attributes: [
3690
- [{ value: '\\Subscribed' }],
3691
- { value: '/' },
3692
- { value: '/Folder1' } // Leading delimiter
3693
- ]
3694
- });
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
+ }
3695
3934
  }
3696
3935
  return { next: () => {} };
3697
3936
  }
3698
3937
  });
3699
3938
 
3700
- const result = await listCommand(connection, '', '*');
3701
- const folder = result.find(e => e.path === 'Folder1');
3702
- test.ok(folder);
3703
- test.equal(folder.subscribed, true);
3939
+ await listCommand(connection, '', '*', { listOnly: true });
3940
+ // Just reference and pattern, no RETURN block
3941
+ test.equal(listAttrs.length, 2);
3704
3942
  test.done();
3705
3943
  };
3706
3944
 
3707
- module.exports['Commands: list sorts non-special-use after special-use'] = async test => {
3945
+ module.exports['Commands: list survives LSUB rejection'] = async test => {
3946
+ let lsubCalls = 0;
3708
3947
  const connection = createMockConnection({
3709
3948
  state: 3,
3710
- capabilities: new Map([['SPECIAL-USE', true]]),
3711
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
+ }
3712
3955
  if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3713
- // Regular folder first
3714
3956
  await opts.untagged.LIST({
3715
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'ZFolder' }]
3957
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3716
3958
  });
3717
- // Then INBOX (special use)
3718
3959
  await opts.untagged.LIST({
3719
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3960
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
3720
3961
  });
3721
3962
  }
3722
3963
  return { next: () => {} };
@@ -3724,22 +3965,808 @@ module.exports['Commands: list sorts non-special-use after special-use'] = async
3724
3965
  });
3725
3966
 
3726
3967
  const result = await listCommand(connection, '', '*');
3727
- // INBOX (special use) should come before ZFolder (no special use)
3728
- const inboxIndex = result.findIndex(e => e.path === 'INBOX');
3729
- const zFolderIndex = result.findIndex(e => e.path === 'ZFolder');
3730
- test.ok(inboxIndex < zFolderIndex, 'Special use folders should sort before non-special-use');
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);
3731
3978
  test.done();
3732
3979
  };
3733
3980
 
3734
- module.exports['Commands: list sorts alphabetically when no special use'] = async test => {
3981
+ module.exports['Commands: list fails when LSUB dies without a server rejection'] = async test => {
3735
3982
  const connection = createMockConnection({
3736
3983
  state: 3,
3737
- capabilities: new Map([['SPECIAL-USE', true]]),
3738
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
+ }
3739
3989
  if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3740
- // Folders without special use in reverse alphabetical order
3741
3990
  await opts.untagged.LIST({
3742
- attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Zebra' }]
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);
4396
+ test.done();
4397
+ };
4398
+
4399
+ module.exports['Commands: list discards partial results from a rejected INBOX fixup'] = async test => {
4400
+ let listAttempts = [];
4401
+ const connection = createMockConnection({
4402
+ state: 3,
4403
+ capabilities: new Map([
4404
+ ['IMAP4rev1', true],
4405
+ ['LIST-EXTENDED', true]
4406
+ ]),
4407
+ exec: async (cmd, attrs, opts) => {
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
+ }
4426
+ }
4427
+ return { next: () => {} };
4428
+ }
4429
+ });
4430
+
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'));
4436
+ test.done();
4437
+ };
4438
+
4439
+ module.exports['Commands: list latches only the auxiliary options when the server rejects them'] = async test => {
4440
+ let listAttempts = [];
4441
+ const connection = createMockConnection({
4442
+ state: 3,
4443
+ capabilities: new Map([
4444
+ ['IMAP4rev1', true],
4445
+ ['LIST-EXTENDED', true],
4446
+ ['LIST-STATUS', true],
4447
+ ['SPECIAL-USE', true]
4448
+ ]),
4449
+ exec: async (cmd, attrs, opts) => {
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
+ }
4456
+ await opts.untagged.LIST({
4457
+ attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4458
+ });
4459
+ if (opts.untagged.STATUS) {
4460
+ await opts.untagged.STATUS({
4461
+ attributes: [{ value: 'Folder1' }, [{ value: 'MESSAGES' }, { value: '3' }]]
4462
+ });
4463
+ }
4464
+ }
4465
+ return { next: () => {} };
4466
+ }
4467
+ });
4468
+
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'));
4491
+ test.done();
4492
+ };
4493
+
4494
+ module.exports['Commands: list falls back to LSUB when RETURN (SUBSCRIBED) is silently ignored'] = async test => {
4495
+ let lsubCalled = false;
4496
+ const connection = createMockConnection({
4497
+ state: 3,
4498
+ capabilities: new Map([
4499
+ ['IMAP4rev1', true],
4500
+ ['LIST-EXTENDED', true]
4501
+ ]),
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
+ }
4509
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4510
+ // Server accepted RETURN (SUBSCRIBED) but returned no \Subscribed flags
4511
+ await opts.untagged.LIST({
4512
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4513
+ });
4514
+ }
4515
+ return { next: () => {} };
4516
+ }
4517
+ });
4518
+
4519
+ const result = await listCommand(connection, '', '*');
4520
+ test.equal(lsubCalled, true);
4521
+ test.equal(result.find(e => e.path === 'Folder1').subscribed, true);
4522
+ test.done();
4523
+ };
4524
+
4525
+ module.exports['Commands: list skips the LSUB safety net on rev2 sessions'] = async test => {
4526
+ let lsubCalled = false;
4527
+ const connection = createMockConnection({
4528
+ state: 3,
4529
+ capabilities: new Map([['IMAP4rev2', true]]),
4530
+ exec: async (cmd, attrs, opts) => {
4531
+ if (cmd === 'LSUB') {
4532
+ lsubCalled = true;
4533
+ }
4534
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4535
+ // No folder is subscribed - legitimate on a fresh account, and rev2
4536
+ // removed LSUB so there is nothing to fall back to
4537
+ await opts.untagged.LIST({
4538
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4539
+ });
4540
+ }
4541
+ return { next: () => {} };
4542
+ }
4543
+ });
4544
+
4545
+ await listCommand(connection, '', '*');
4546
+ test.equal(lsubCalled, false);
4547
+ test.done();
4548
+ };
4549
+
4550
+ module.exports['Commands: list honors special-use flags on rev2-only servers'] = async test => {
4551
+ const connection = createMockConnection({
4552
+ state: 3,
4553
+ capabilities: new Map([['IMAP4rev2', true]]),
4554
+ exec: async (cmd, attrs, opts) => {
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
4558
+ await opts.untagged.LIST({
4559
+ attributes: [[{ value: '\\Sent' }, { value: '\\HasNoChildren' }], { value: '/' }, { value: 'Custom-Sent-Name' }]
4560
+ });
4561
+ }
4562
+ return { next: () => {} };
4563
+ }
4564
+ });
4565
+
4566
+ const result = await listCommand(connection, '', '*');
4567
+ const folder = result.find(e => e.path === 'Custom-Sent-Name');
4568
+ test.equal(folder.specialUse, '\\Sent');
4569
+ test.done();
4570
+ };
4571
+
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;
4575
+ const connection = createMockConnection({
4576
+ state: 3,
4577
+ capabilities: new Map([['IMAP4rev2', true]]),
4578
+ run: async cmd => {
4579
+ if (cmd === 'STATUS') {
4580
+ statusCommands++;
4581
+ }
4582
+ return {};
4583
+ },
4584
+ exec: async (cmd, attrs, opts) => {
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
+ }
4597
+ }
4598
+ return { next: () => {} };
4599
+ }
4600
+ });
4601
+
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);
4611
+ test.done();
4612
+ };
4613
+
4614
+ module.exports['Commands: list does not let NonExistent phantoms win special-use by name'] = async test => {
4615
+ const connection = createMockConnection({
4616
+ state: 3,
4617
+ exec: async (cmd, attrs, opts) => {
4618
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4619
+ // Phantom subscription leftover of a deleted folder
4620
+ await opts.untagged.LIST({
4621
+ attributes: [[{ value: '\\NonExistent' }, { value: '\\Subscribed' }], { value: '/' }, { value: 'Sent' }]
4622
+ });
4623
+ // The real sent-mail folder, matched by name
4624
+ await opts.untagged.LIST({
4625
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Sent Messages' }]
4626
+ });
4627
+ }
4628
+ if (cmd === 'LSUB') {
4629
+ throw commandError('Command failed', 'BAD');
4630
+ }
4631
+ return { next: () => {} };
4632
+ }
4633
+ });
4634
+
4635
+ const result = await listCommand(connection, '', '*');
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');
4641
+ test.done();
4642
+ };
4643
+
4644
+ module.exports['Commands: list does not STATUS NonExistent mailboxes'] = async test => {
4645
+ let statusPaths = [];
4646
+ const connection = createMockConnection({
4647
+ state: 3,
4648
+ capabilities: new Map(),
4649
+ run: async (cmd, path) => {
4650
+ if (cmd === 'STATUS') {
4651
+ statusPaths.push(path);
4652
+ return { messages: 10 };
4653
+ }
4654
+ },
4655
+ exec: async (cmd, attrs, opts) => {
4656
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4657
+ await opts.untagged.LIST({
4658
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4659
+ });
4660
+ await opts.untagged.LIST({
4661
+ attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Phantom' }]
4662
+ });
4663
+ }
4664
+ return { next: () => {} };
4665
+ }
4666
+ });
4667
+
4668
+ await listCommand(connection, '', '*', { statusQuery: { messages: true } });
4669
+ // STATUS runs for the selectable mailbox only
4670
+ test.deepEqual(statusPaths, ['INBOX']);
4671
+ test.done();
4672
+ };
4673
+
4674
+ module.exports['Commands: list XLIST removes Inbox flag from non-INBOX'] = async test => {
4675
+ const connection = createMockConnection({
4676
+ state: 3,
4677
+ capabilities: new Map([['XLIST', true]]),
4678
+ exec: async (cmd, attrs, opts) => {
4679
+ if (cmd === 'XLIST' && opts && opts.untagged && opts.untagged.XLIST) {
4680
+ // XLIST may have localised inbox name with \\Inbox flag
4681
+ await opts.untagged.XLIST({
4682
+ attributes: [
4683
+ [{ value: '\\Inbox' }, { value: '\\HasNoChildren' }],
4684
+ { value: '/' },
4685
+ { value: 'Posteingang' } // German for inbox
4686
+ ]
4687
+ });
4688
+ }
4689
+ return { next: () => {} };
4690
+ }
4691
+ });
4692
+
4693
+ const result = await listCommand(connection, '', '*');
4694
+ const folder = result.find(e => e.path === 'Posteingang');
4695
+ test.ok(folder);
4696
+ // \\Inbox flag should be removed from flags set
4697
+ test.equal(folder.flags.has('\\Inbox'), false);
4698
+ // But it should have \\Inbox special use
4699
+ test.equal(folder.specialUse, '\\Inbox');
4700
+ test.done();
4701
+ };
4702
+
4703
+ module.exports['Commands: list LSUB path with leading delimiter'] = async test => {
4704
+ const connection = createMockConnection({
4705
+ state: 3,
4706
+ capabilities: new Map([['SPECIAL-USE', true]]),
4707
+ exec: async (cmd, attrs, opts) => {
4708
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4709
+ await opts.untagged.LIST({
4710
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4711
+ });
4712
+ }
4713
+ if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
4714
+ // LSUB returns path with leading delimiter
4715
+ await opts.untagged.LSUB({
4716
+ attributes: [
4717
+ [{ value: '\\Subscribed' }],
4718
+ { value: '/' },
4719
+ { value: '/Folder1' } // Leading delimiter
4720
+ ]
4721
+ });
4722
+ }
4723
+ return { next: () => {} };
4724
+ }
4725
+ });
4726
+
4727
+ const result = await listCommand(connection, '', '*');
4728
+ const folder = result.find(e => e.path === 'Folder1');
4729
+ test.ok(folder);
4730
+ test.equal(folder.subscribed, true);
4731
+ test.done();
4732
+ };
4733
+
4734
+ module.exports['Commands: list sorts non-special-use after special-use'] = async test => {
4735
+ const connection = createMockConnection({
4736
+ state: 3,
4737
+ capabilities: new Map([['SPECIAL-USE', true]]),
4738
+ exec: async (cmd, attrs, opts) => {
4739
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4740
+ // Regular folder first
4741
+ await opts.untagged.LIST({
4742
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'ZFolder' }]
4743
+ });
4744
+ // Then INBOX (special use)
4745
+ await opts.untagged.LIST({
4746
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
4747
+ });
4748
+ }
4749
+ return { next: () => {} };
4750
+ }
4751
+ });
4752
+
4753
+ const result = await listCommand(connection, '', '*');
4754
+ // INBOX (special use) should come before ZFolder (no special use)
4755
+ const inboxIndex = result.findIndex(e => e.path === 'INBOX');
4756
+ const zFolderIndex = result.findIndex(e => e.path === 'ZFolder');
4757
+ test.ok(inboxIndex < zFolderIndex, 'Special use folders should sort before non-special-use');
4758
+ test.done();
4759
+ };
4760
+
4761
+ module.exports['Commands: list sorts alphabetically when no special use'] = async test => {
4762
+ const connection = createMockConnection({
4763
+ state: 3,
4764
+ capabilities: new Map([['SPECIAL-USE', true]]),
4765
+ exec: async (cmd, attrs, opts) => {
4766
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4767
+ // Folders without special use in reverse alphabetical order
4768
+ await opts.untagged.LIST({
4769
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Zebra' }]
3743
4770
  });
3744
4771
  await opts.untagged.LIST({
3745
4772
  attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Alpha' }]
@@ -4673,6 +5700,48 @@ module.exports['Commands: status skips when no query attributes'] = async test =
4673
5700
  test.done();
4674
5701
  };
4675
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
+
4676
5745
  module.exports['Commands: status skips when all query values are false'] = async test => {
4677
5746
  const connection = createMockConnection({ state: 2 });
4678
5747
 
@@ -5772,6 +6841,27 @@ module.exports['Commands: idle with IDLE capability'] = async test => {
5772
6841
  test.done();
5773
6842
  };
5774
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
+
5775
6865
  module.exports['Commands: idle skips when not selected'] = async test => {
5776
6866
  const connection = createMockConnection({ state: 2 }); // AUTHENTICATED
5777
6867
 
@@ -8608,10 +9698,11 @@ module.exports['Commands: enable updates connection.enabled'] = async test => {
8608
9698
  });
8609
9699
 
8610
9700
  await enableCommand(connection, ['CONDSTORE', 'UTF8=ACCEPT']);
8611
- // 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)
8612
9703
  test.ok(connection.enabled.has('CONDSTORE'));
8613
9704
  test.ok(connection.enabled.has('UTF8=ACCEPT'));
8614
- test.ok(!connection.enabled.has('EXISTING')); // Old value should be gone
9705
+ test.ok(connection.enabled.has('EXISTING'));
8615
9706
  test.done();
8616
9707
  };
8617
9708