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