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