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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/CLAUDE.md +10 -1
- package/Gruntfile.js +3 -1
- package/lib/commands/authenticate.js +8 -3
- package/lib/commands/enable.js +13 -4
- package/lib/commands/expunge.js +2 -2
- package/lib/commands/idle.js +6 -3
- package/lib/commands/list.js +237 -60
- package/lib/commands/move.js +2 -2
- package/lib/commands/namespace.js +3 -1
- package/lib/commands/search.js +88 -13
- package/lib/commands/status.js +13 -25
- package/lib/imap-flow.d.ts +5 -3
- package/lib/imap-flow.js +39 -7
- package/lib/search-compiler.js +15 -1
- package/lib/tools.js +141 -9
- package/package.json +3 -2
- package/test/commands-branches-test.js +11 -4
- package/test/commands-integration-test.js +1163 -72
- package/test/fixtures/test-tls.js +2 -2
- package/test/imap-flow-coverage-test.js +8 -1
- package/test/imap-flow-fetch-download-test.js +1 -4
- package/test/imap-flow-internals-test.js +2 -2
- package/test/imap-flow-methods-test.js +65 -6
- package/test/imap-parser-test.js +1 -2
- package/test/integration/README.md +40 -0
- package/test/integration/dovecot-test.conf +27 -0
- package/test/integration/rev2-live-test.js +242 -0
- package/test/integration/run-rev2-tests.sh +61 -0
- package/test/reliability-improvements-test.js +4 -1
- package/test/search-compiler-test.js +19 -0
- package/test/search-test.js +52 -54
- package/test/tools-test.js +134 -15
|
@@ -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
|
|
3618
|
-
let
|
|
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 === '
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
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, '', '*'
|
|
3642
|
-
//
|
|
3643
|
-
test.
|
|
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
|
|
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([
|
|
3892
|
+
capabilities: new Map([
|
|
3893
|
+
['IMAP4rev1', true],
|
|
3894
|
+
['LIST-EXTENDED', true]
|
|
3895
|
+
]),
|
|
3651
3896
|
exec: async (cmd, attrs, opts) => {
|
|
3652
|
-
if (cmd === '
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
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
|
-
|
|
3668
|
-
test.ok(
|
|
3669
|
-
|
|
3670
|
-
test.
|
|
3671
|
-
|
|
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
|
|
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([['
|
|
3925
|
+
capabilities: new Map([['IMAP4rev2', true]]),
|
|
3680
3926
|
exec: async (cmd, attrs, opts) => {
|
|
3681
|
-
if (cmd === 'LIST'
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
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
|
-
|
|
3701
|
-
|
|
3702
|
-
test.
|
|
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
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
3728
|
-
const
|
|
3729
|
-
|
|
3730
|
-
|
|
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
|
|
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: '
|
|
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
|
-
//
|
|
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(
|
|
9705
|
+
test.ok(connection.enabled.has('EXISTING'));
|
|
8615
9706
|
test.done();
|
|
8616
9707
|
};
|
|
8617
9708
|
|