imapflow 1.6.5 → 1.6.6

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.
@@ -59,6 +59,10 @@ const createMockConnection = (overrides = {}) => {
59
59
  skipListAuxArgs: false,
60
60
  skipLsub: false,
61
61
  messageFlagsAdd: overrides.messageFlagsAdd || (async () => {}),
62
+ // Mirrors ImapFlow.throttleWait(): resolves false on normal expiry, true when close()
63
+ // aborted the wait. The mock resolves immediately so throttle retries stay fast.
64
+ throttleWait: overrides.throttleWait || (async () => false),
65
+ createNoConnectionError: overrides.createNoConnectionError || (() => Object.assign(new Error('Connection not available'), { code: 'NoConnection' })),
62
66
  run: overrides.run || (async () => {}),
63
67
  // Mirrors ImapFlow.runInternal(): dispatch through the command registry without the
64
68
  // preCheck/auto-IDLE handshake that run() performs, so a fallback poll runs the real
@@ -10439,3 +10443,682 @@ module.exports['Commands: downloadMany with fetchOne returning null'] = async te
10439
10443
  test.equal(result.response, false);
10440
10444
  test.done();
10441
10445
  };
10446
+
10447
+ // ============================================
10448
+ // Security regression tests: hostile server input
10449
+ // ============================================
10450
+
10451
+ module.exports['Commands: quota ignores prototype-chain and fixed-field resource names'] = async test => {
10452
+ const connection = createMockConnection({
10453
+ state: 2,
10454
+ capabilities: new Map([['QUOTA', true]]),
10455
+ exec: async (cmd, args, opts) => {
10456
+ if (opts && opts.untagged && opts.untagged.QUOTA) {
10457
+ await opts.untagged.QUOTA({
10458
+ attributes: [
10459
+ { value: '' },
10460
+ [
10461
+ { value: '__PROTO__' },
10462
+ { value: '10' },
10463
+ { value: '100' },
10464
+ { value: 'CONSTRUCTOR' },
10465
+ { value: '1' },
10466
+ { value: '2' },
10467
+ { value: 'PATH' },
10468
+ { value: '3' },
10469
+ { value: '4' },
10470
+ { value: 'STORAGE' },
10471
+ { value: '250' },
10472
+ { value: '500' }
10473
+ ]
10474
+ ]
10475
+ });
10476
+ }
10477
+ return { next: () => {} };
10478
+ }
10479
+ });
10480
+
10481
+ const result = await quotaCommand(connection, 'INBOX');
10482
+ // Capture before cleaning up: deleting first would erase exactly the evidence the
10483
+ // assertion looks for, and the test would then pass with the guard removed. The cleanup
10484
+ // still has to happen so a regression cannot leak into the rest of the suite.
10485
+ let leaked = ['usage', 'limit', 'status'].filter(key => Object.hasOwn(Object.prototype, key));
10486
+ delete Object.prototype.usage;
10487
+ delete Object.prototype.limit;
10488
+ delete Object.prototype.status;
10489
+ test.deepEqual(leaked, [], 'Object.prototype must stay clean');
10490
+ test.equal(result.path, 'INBOX', 'the fixed path field must not be overwritten');
10491
+ test.ok(!Object.prototype.hasOwnProperty.call(result, 'constructor'));
10492
+ test.equal(result.storage.usage, 250 * 1024);
10493
+ test.equal(result.storage.limit, 500 * 1024);
10494
+ test.done();
10495
+ };
10496
+
10497
+ module.exports['Commands: select ignores unknown response codes on the mailbox object'] = async test => {
10498
+ const connection = createMockConnection({
10499
+ state: 2, // AUTHENTICATED
10500
+ folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
10501
+ run: async () => [],
10502
+ exec: async (cmd, attrs, opts) => {
10503
+ if (opts && opts.untagged) {
10504
+ if (opts.untagged.OK) {
10505
+ // A response code the client does not know must not become a mailbox
10506
+ // property: "PATH" would otherwise overwrite mailbox.path (defeating the
10507
+ // DELETE/RENAME guards), and "__PROTO__" with a list value would replace
10508
+ // the object's prototype
10509
+ await opts.untagged.OK({
10510
+ attributes: [{ section: [{ type: 'ATOM', value: 'PATH' }, { value: 'INBOX.evil' }] }]
10511
+ });
10512
+ await opts.untagged.OK({
10513
+ attributes: [{ section: [{ type: 'ATOM', value: '__PROTO__' }, [{ value: 'polluted' }]] }]
10514
+ });
10515
+ await opts.untagged.OK({
10516
+ attributes: [{ section: [{ type: 'ATOM', value: 'UIDNEXT' }, { value: '1000' }] }]
10517
+ });
10518
+ // malformed codes must not crash the handler or corrupt state either:
10519
+ // a NIL key, a NIL value, and a UIDNEXT digit run that overflows to Infinity
10520
+ await opts.untagged.OK({
10521
+ attributes: [{ section: [null] }]
10522
+ });
10523
+ await opts.untagged.OK({
10524
+ attributes: [{ section: [{ type: 'ATOM', value: 'UIDNEXT' }, null] }]
10525
+ });
10526
+ await opts.untagged.OK({
10527
+ attributes: [{ section: [{ type: 'ATOM', value: 'UIDNEXT' }, { value: '9'.repeat(400) }] }]
10528
+ });
10529
+ }
10530
+ if (opts.untagged.EXISTS) {
10531
+ // an overflowing count must be ignored, not stored as Infinity
10532
+ await opts.untagged.EXISTS({ command: '9'.repeat(400) });
10533
+ }
10534
+ }
10535
+ return {
10536
+ next: () => {},
10537
+ response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
10538
+ };
10539
+ },
10540
+ emit: () => {}
10541
+ });
10542
+
10543
+ const result = await selectCommand(connection, 'INBOX');
10544
+ test.equal(result.path, 'INBOX', 'a PATH response code must not overwrite mailbox.path');
10545
+ test.equal(result.uidNext, 1000, 'malformed UIDNEXT values must not replace a good one');
10546
+ test.equal(result.exists, undefined, 'an overflowing EXISTS count must be ignored');
10547
+ test.equal(result.polluted, undefined);
10548
+ // A "__proto__" key with a list value replaces the object's prototype rather than creating
10549
+ // an own property, so the own-property check alone would hold with the guard removed too -
10550
+ // the prototype identity is what actually detects it
10551
+ test.equal(Object.getPrototypeOf(result), Object.prototype, 'the mailbox object must keep its prototype');
10552
+ test.ok(!Object.prototype.hasOwnProperty.call(result, '__proto__'));
10553
+ test.done();
10554
+ };
10555
+
10556
+ module.exports['Commands: downloadMany ignores prototype-chain part keys'] = async test => {
10557
+ // The server chooses the BODY[...] keys in its FETCH answers: without a guard a
10558
+ // "__proto__" key wrote attacker-controlled content onto Object.prototype
10559
+ let client = new ImapFlow({
10560
+ host: 'imap.example.com',
10561
+ port: 993,
10562
+ auth: { user: 'test', pass: 'test' }
10563
+ });
10564
+ client.mailbox = { path: 'INBOX' };
10565
+ client.fetchOne = async () => ({
10566
+ bodyParts: new Map([
10567
+ ['__proto__', Buffer.from('polluted')],
10568
+ ['2.mime', Buffer.from('Content-Type: text/plain\r\n\r\n')],
10569
+ ['2', Buffer.from('real content')]
10570
+ ])
10571
+ });
10572
+
10573
+ let result = await client.downloadMany('1', ['2']);
10574
+ // Capture before cleaning up: deleting first would erase exactly the evidence the
10575
+ // assertion looks for, and the test would then pass with the guard removed
10576
+ let leaked = Object.hasOwn(Object.prototype, 'content') || Object.hasOwn(Object.prototype, 'meta');
10577
+ delete Object.prototype.content;
10578
+ delete Object.prototype.meta;
10579
+ test.equal(leaked, false, 'Object.prototype must stay clean');
10580
+ test.ok(result['2']);
10581
+ test.equal(result['2'].content.toString(), 'real content');
10582
+ test.equal(result['2'].meta.contentType, 'text/plain');
10583
+ test.done();
10584
+ };
10585
+
10586
+ // ============================================
10587
+ // Untrusted response value handling
10588
+ // ============================================
10589
+ // Servers control every value in these responses. Each test below pins a value the client
10590
+ // must refuse to store, or a malformed shape it must survive without losing the response.
10591
+
10592
+ const { canUseFlag } = require('../lib/tools.js');
10593
+
10594
+ const selectWithOkCodes = sections =>
10595
+ createMockConnection({
10596
+ state: 2, // AUTHENTICATED
10597
+ folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
10598
+ run: async () => [],
10599
+ exec: async (cmd, attrs, opts) => {
10600
+ if (opts && opts.untagged && opts.untagged.OK) {
10601
+ for (let section of sections) {
10602
+ await opts.untagged.OK({ attributes: [{ section }] });
10603
+ }
10604
+ }
10605
+ return {
10606
+ next: () => {},
10607
+ response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
10608
+ };
10609
+ },
10610
+ emit: () => {}
10611
+ });
10612
+
10613
+ module.exports['Commands: select leaves permanentFlags unset for a NIL PERMANENTFLAGS'] = async test => {
10614
+ // An empty Set is not the same as "unset": canUseFlag() treats unset as permissive and an
10615
+ // empty set as deny-all, which would silently turn every later flag update into a no-op
10616
+ const result = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'PERMANENTFLAGS' }, null]]), 'INBOX');
10617
+ test.equal(result.permanentFlags, undefined, 'a NIL value must not produce an empty flag set');
10618
+ test.ok(canUseFlag(result, '\\Seen'), 'flag updates must stay permitted');
10619
+ test.done();
10620
+ };
10621
+
10622
+ module.exports['Commands: select ignores an unparenthesized PERMANENTFLAGS value'] = async test => {
10623
+ // new Set('\\Seen') would be a set of the individual characters
10624
+ const result = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'PERMANENTFLAGS' }, { value: '\\Seen' }]]), 'INBOX');
10625
+ test.equal(result.permanentFlags, undefined);
10626
+ test.ok(canUseFlag(result, '\\Seen'));
10627
+ test.done();
10628
+ };
10629
+
10630
+ module.exports['Commands: select survives NIL entries inside a PERMANENTFLAGS list'] = async test => {
10631
+ const result = await selectCommand(
10632
+ selectWithOkCodes([[{ type: 'ATOM', value: 'PERMANENTFLAGS' }, [{ value: '\\Seen' }, null, { value: '\\Draft' }]]]),
10633
+ 'INBOX'
10634
+ );
10635
+ test.deepEqual(Array.from(result.permanentFlags), ['\\Seen', '\\Draft']);
10636
+ test.done();
10637
+ };
10638
+
10639
+ module.exports['Commands: select survives NIL entries inside a FLAGS response'] = async test => {
10640
+ const connection = createMockConnection({
10641
+ state: 2,
10642
+ folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
10643
+ run: async () => [],
10644
+ exec: async (cmd, attrs, opts) => {
10645
+ if (opts && opts.untagged && opts.untagged.FLAGS) {
10646
+ await opts.untagged.FLAGS({ attributes: [[{ value: '\\Seen' }, null, { value: '\\Flagged' }]] });
10647
+ }
10648
+ return { next: () => {}, response: { attributes: [] } };
10649
+ },
10650
+ emit: () => {}
10651
+ });
10652
+
10653
+ const result = await selectCommand(connection, 'INBOX');
10654
+ test.deepEqual(Array.from(result.flags), ['\\Seen', '\\Flagged'], 'a NIL entry must not cost the whole flag list');
10655
+ test.done();
10656
+ };
10657
+
10658
+ module.exports['Commands: select handles a tagged OK carrying no response text'] = async test => {
10659
+ // "A1 OK" parses to an object with no `attributes` property at all. Reading through it in
10660
+ // the command body would land in the outer catch, which tears down the mailbox state the
10661
+ // server has actually selected and rejects the caller with a TypeError.
10662
+ const connection = createMockConnection({
10663
+ state: 2,
10664
+ folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
10665
+ run: async () => [],
10666
+ exec: async (cmd, attrs, opts) => {
10667
+ if (opts && opts.untagged && opts.untagged.EXISTS) {
10668
+ await opts.untagged.EXISTS({ command: '42' });
10669
+ }
10670
+ return { next: () => {}, response: { tag: 'A1', command: 'OK' } };
10671
+ },
10672
+ emit: () => {}
10673
+ });
10674
+
10675
+ const result = await selectCommand(connection, 'INBOX');
10676
+ test.equal(result.path, 'INBOX');
10677
+ test.equal(result.exists, 42, 'collected mailbox data must survive');
10678
+ test.equal(result.readOnly, undefined);
10679
+ test.equal(connection.state, connection.states.SELECTED, 'the mailbox must stay selected');
10680
+ test.done();
10681
+ };
10682
+
10683
+ module.exports['Commands: select exposes UNSEEN and APPENDLIMIT'] = async test => {
10684
+ const result = await selectCommand(
10685
+ selectWithOkCodes([
10686
+ [{ type: 'ATOM', value: 'UNSEEN' }, { value: '12' }],
10687
+ [{ type: 'ATOM', value: 'APPENDLIMIT' }, { value: '35651584' }]
10688
+ ]),
10689
+ 'INBOX'
10690
+ );
10691
+ test.equal(result.unseen, 12);
10692
+ test.equal(result.appendlimit, 35651584);
10693
+ test.done();
10694
+ };
10695
+
10696
+ module.exports['Commands: select drops an unusable HIGHESTMODSEQ instead of storing it raw'] = async test => {
10697
+ // A non-numeric string stored in a BigInt field compares false in both directions, so the
10698
+ // value could never advance again and CONDSTORE/QRESYNC delta sync would stop for good
10699
+ for (let bad of ['none', '1e5', '-1', '', '9'.repeat(20)]) {
10700
+ const result = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'HIGHESTMODSEQ' }, { value: bad }]]), 'INBOX');
10701
+ test.equal(result.highestModseq, undefined, `HIGHESTMODSEQ ${JSON.stringify(bad)} must be dropped`);
10702
+ }
10703
+
10704
+ const good = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'HIGHESTMODSEQ' }, { value: '9122' }]]), 'INBOX');
10705
+ test.equal(good.highestModseq, 9122n);
10706
+ test.done();
10707
+ };
10708
+
10709
+ module.exports['Commands: select accepts only decimal UIDNEXT values'] = async test => {
10710
+ for (let bad of ['0x10', '1e3', ' 12 ', '-5', '1.5']) {
10711
+ const result = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'UIDNEXT' }, { value: bad }]]), 'INBOX');
10712
+ test.equal(result.uidNext, undefined, `UIDNEXT ${JSON.stringify(bad)} must be dropped`);
10713
+ }
10714
+
10715
+ const good = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'UIDNEXT' }, { value: '1000' }]]), 'INBOX');
10716
+ test.equal(good.uidNext, 1000);
10717
+ test.done();
10718
+ };
10719
+
10720
+ module.exports['Commands: append survives a malformed APPENDUID'] = async test => {
10721
+ // BigInt('1e5') throws where isNaN('1e5') passes, and append rethrows - the message is
10722
+ // already stored at that point, so a retrying caller would duplicate it
10723
+ const connection = createMockConnection({
10724
+ state: 2,
10725
+ folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
10726
+ exec: async () => ({
10727
+ next: () => {},
10728
+ response: {
10729
+ attributes: [
10730
+ {
10731
+ section: [{ type: 'ATOM', value: 'APPENDUID' }, { value: '1e5' }, { value: '7' }]
10732
+ }
10733
+ ]
10734
+ }
10735
+ })
10736
+ });
10737
+
10738
+ const result = await appendCommand(connection, 'INBOX', Buffer.from('test'));
10739
+ test.ok(result, 'append must not reject on an unusable APPENDUID');
10740
+ test.equal(result.uidValidity, undefined, 'the unusable uidValidity is dropped');
10741
+ test.equal(result.uid, 7, 'the usable uid is still reported');
10742
+ test.done();
10743
+ };
10744
+
10745
+ module.exports['Commands: append ignores an overflowing EXISTS count'] = async test => {
10746
+ // Number('9'.repeat(400)) is Infinity, and resolveRange('*') would then compile the literal
10747
+ // string "Infinity" into every later range-based command
10748
+ let emitted = [];
10749
+ const connection = createMockConnection({
10750
+ state: 3,
10751
+ mailbox: { path: 'INBOX', exists: 5, flags: new Set(), permanentFlags: new Set(['\\*']) },
10752
+ folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
10753
+ emit: (name, payload) => emitted.push([name, payload]),
10754
+ exec: async (cmd, attrs, opts) => {
10755
+ if (opts && opts.untagged && opts.untagged.EXISTS) {
10756
+ await opts.untagged.EXISTS({ command: '9'.repeat(400) });
10757
+ }
10758
+ return { next: () => {}, response: { attributes: [] } };
10759
+ }
10760
+ });
10761
+
10762
+ await appendCommand(connection, 'INBOX', Buffer.from('test'));
10763
+ test.equal(connection.mailbox.exists, 5, 'the live message count must be left alone');
10764
+ test.equal(emitted.filter(entry => entry[0] === 'exists').length, 0, 'no exists event may be emitted for an unusable count');
10765
+ test.done();
10766
+ };
10767
+
10768
+ module.exports['Commands: expunge survives a malformed HIGHESTMODSEQ'] = async test => {
10769
+ const connection = createMockConnection({
10770
+ state: 3,
10771
+ mailbox: { path: 'INBOX', highestModseq: 100n },
10772
+ exec: async () => ({
10773
+ next: () => {},
10774
+ response: {
10775
+ attributes: [{ section: [{ type: 'ATOM', value: 'HIGHESTMODSEQ' }, { value: '1e5' }] }]
10776
+ }
10777
+ })
10778
+ });
10779
+
10780
+ const result = await expungeCommand(connection, '1:*', {});
10781
+ test.equal(result, true, 'the expunge did happen, so it must not be reported as failed');
10782
+ test.equal(connection.mailbox.highestModseq, 100n, 'the unusable value is not stored');
10783
+ test.done();
10784
+ };
10785
+
10786
+ module.exports['Commands: status skips one malformed field and keeps the rest'] = async test => {
10787
+ const connection = createMockConnection({
10788
+ state: 2,
10789
+ exec: async (cmd, attrs, opts) => {
10790
+ if (opts && opts.untagged && opts.untagged.STATUS) {
10791
+ await opts.untagged.STATUS({
10792
+ attributes: [
10793
+ { value: 'INBOX' },
10794
+ [{ value: 'MESSAGES' }, { value: '1e5' }, { value: 'UIDNEXT' }, { value: '10' }, { value: 'UIDVALIDITY' }, { value: '99' }]
10795
+ ]
10796
+ });
10797
+ }
10798
+ return { next: () => {} };
10799
+ }
10800
+ });
10801
+
10802
+ const result = await statusCommand(connection, 'INBOX', { messages: true, uidNext: true, uidValidity: true });
10803
+ test.equal(result.messages, undefined, 'the unusable field is dropped');
10804
+ test.equal(result.uidNext, 10, 'later fields must still be parsed');
10805
+ test.equal(result.uidValidity, 99n);
10806
+ test.done();
10807
+ };
10808
+
10809
+ module.exports['Commands: status ignores an overflowing MESSAGES count for the selected mailbox'] = async test => {
10810
+ let emitted = [];
10811
+ const connection = createMockConnection({
10812
+ state: 3,
10813
+ mailbox: { path: 'INBOX', exists: 5 },
10814
+ emit: (name, payload) => emitted.push([name, payload]),
10815
+ exec: async (cmd, attrs, opts) => {
10816
+ if (opts && opts.untagged && opts.untagged.STATUS) {
10817
+ await opts.untagged.STATUS({
10818
+ attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '9'.repeat(400) }]]
10819
+ });
10820
+ }
10821
+ return { next: () => {} };
10822
+ }
10823
+ });
10824
+
10825
+ const result = await statusCommand(connection, 'INBOX', { messages: true });
10826
+ test.equal(result.messages, undefined);
10827
+ test.equal(connection.mailbox.exists, 5, 'the live message count must be left alone');
10828
+ test.equal(emitted.filter(entry => entry[0] === 'exists').length, 0);
10829
+ test.done();
10830
+ };
10831
+
10832
+ module.exports['Commands: search drops out-of-range values from an untagged SEARCH'] = async test => {
10833
+ // isNaN() passes '1e400' (Infinity), '-3' and '2.5'; a single one of those makes the
10834
+ // sequence set compiled from this result invalid and fails the caller's follow-up command
10835
+ const connection = createMockConnection({
10836
+ state: 3,
10837
+ exec: async (cmd, attrs, opts) => {
10838
+ if (opts && opts.untagged && opts.untagged.SEARCH) {
10839
+ await opts.untagged.SEARCH({
10840
+ attributes: [
10841
+ { value: '1e400' },
10842
+ { value: '-3' },
10843
+ { value: '2.5' },
10844
+ { value: '0' },
10845
+ null, // a parsed NIL
10846
+ { value: ['1'] }, // a parenthesized value where a number belongs
10847
+ { value: '2' },
10848
+ { value: '7' }
10849
+ ]
10850
+ });
10851
+ }
10852
+ return { next: () => {} };
10853
+ }
10854
+ });
10855
+
10856
+ const result = await searchCommand(connection, { all: true }, {});
10857
+ test.deepEqual(result, [2, 7], 'only valid nz-numbers may enter the result set');
10858
+ test.done();
10859
+ };
10860
+
10861
+ module.exports['Commands: downloadMany yields a part that arrived without its MIME headers'] = async test => {
10862
+ // A server may legally answer with fewer items than were requested. One part missing its
10863
+ // companion BODY[<part>.MIME] must not cost the caller the whole download.
10864
+ let client = new ImapFlow({
10865
+ host: 'imap.example.com',
10866
+ port: 993,
10867
+ auth: { user: 'test', pass: 'test' },
10868
+ logger: false
10869
+ });
10870
+ client.mailbox = { path: 'INBOX' };
10871
+ client.fetchOne = async () => ({
10872
+ bodyParts: new Map([['2', Buffer.from('real content')]])
10873
+ });
10874
+
10875
+ let result = await client.downloadMany('1', ['2']);
10876
+ test.equal(result['2'].content.toString(), 'real content');
10877
+ test.deepEqual(result['2'].meta, {}, 'a part with no MIME headers still gets a meta object');
10878
+ test.done();
10879
+ };
10880
+
10881
+ module.exports['Commands: list keeps a LIST-STATUS block when one field is malformed'] = async test => {
10882
+ // BigInt('1e5') throws where isNaN('1e5') passes, and the throw happened before the block
10883
+ // was stored - so one bad field made the whole mailbox's status vanish from the listing
10884
+ const connection = createMockConnection({
10885
+ state: 3,
10886
+ capabilities: new Map([['LIST-STATUS', true]]),
10887
+ exec: async (cmd, attrs, opts) => {
10888
+ if (cmd === 'LIST') {
10889
+ if (opts && opts.untagged && opts.untagged.LIST) {
10890
+ await opts.untagged.LIST({
10891
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
10892
+ });
10893
+ }
10894
+ if (opts && opts.untagged && opts.untagged.STATUS) {
10895
+ await opts.untagged.STATUS({
10896
+ attributes: [
10897
+ { value: 'INBOX' },
10898
+ [{ value: 'HIGHESTMODSEQ' }, { value: '1e5' }, { value: 'MESSAGES' }, { value: '10' }, { value: 'UNSEEN' }, { value: '5' }]
10899
+ ]
10900
+ });
10901
+ }
10902
+ }
10903
+ return { next: () => {} };
10904
+ }
10905
+ });
10906
+
10907
+ const result = await listCommand(connection, '', '*', { statusQuery: { messages: true, unseen: true, highestModseq: true } });
10908
+ const inbox = result.find(entry => entry.path === 'INBOX');
10909
+ test.ok(inbox && inbox.status, 'the status block must survive one unusable field');
10910
+ test.equal(inbox.status.highestModseq, undefined, 'the unusable field is dropped');
10911
+ test.equal(inbox.status.messages, 10);
10912
+ test.equal(inbox.status.unseen, 5);
10913
+ test.done();
10914
+ };
10915
+
10916
+ module.exports['Commands: fetch stops retrying a throttled request once the client closes'] = async test => {
10917
+ // The retry used to wait on a bare setTimeout that close() could not abort: a short-lived
10918
+ // process stayed alive for up to five minutes after close(), still holding the retry
10919
+ let calls = 0;
10920
+ const connection = createMockConnection({
10921
+ state: 3,
10922
+ mailbox: { path: 'INBOX', exists: 1, flags: new Set(), permanentFlags: new Set(), noModseq: true },
10923
+ // the wait reports aborted, which is what close() does to every tracked back-off
10924
+ throttleWait: async () => true,
10925
+ exec: async () => {
10926
+ calls++;
10927
+ const err = new Error('throttled');
10928
+ err.code = 'ETHROTTLE';
10929
+ err.throttleReset = 60000;
10930
+ err.responseText = 'throttled';
10931
+ throw err;
10932
+ }
10933
+ });
10934
+
10935
+ let failure = null;
10936
+ try {
10937
+ await fetchCommand(connection, '1:*', { uid: true }, { uid: true });
10938
+ } catch (err) {
10939
+ failure = err;
10940
+ }
10941
+
10942
+ test.ok(failure, 'the caller is told the fetch did not happen');
10943
+ test.equal(failure.code, 'NoConnection', 'an aborted back-off means the connection is gone');
10944
+ test.equal(calls, 1, 'no retry may be issued on a closed connection');
10945
+ test.done();
10946
+ };
10947
+
10948
+ module.exports['Commands: select accepts an unparenthesized MAILBOXID'] = async test => {
10949
+ // RFC 8474 sends the id as a parenthesized list, but servers in the wild send it bare too
10950
+ const result = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'MAILBOXID' }, { value: 'abc123' }]]), 'INBOX');
10951
+ test.equal(result.mailboxId, 'abc123');
10952
+ test.done();
10953
+ };
10954
+
10955
+ module.exports['Commands: quota ignores a NIL resource value'] = async test => {
10956
+ // A parsed NIL is null, which must not be recorded as a usage of 0
10957
+ const connection = createMockConnection({
10958
+ state: 2,
10959
+ capabilities: new Map([['QUOTA', true]]),
10960
+ exec: async (cmd, attrs, opts) => {
10961
+ if (cmd === 'GETQUOTAROOT' && opts && opts.untagged && opts.untagged.QUOTA) {
10962
+ await opts.untagged.QUOTA({
10963
+ attributes: [{ value: 'root' }, [{ value: 'STORAGE' }, null, { value: '500' }]]
10964
+ });
10965
+ }
10966
+ return { next: () => {} };
10967
+ }
10968
+ });
10969
+
10970
+ const result = await quotaCommand(connection, 'INBOX');
10971
+ test.ok(result, 'the command must actually run');
10972
+ test.equal(result.storage.usage, undefined, 'a NIL usage must not record a zero');
10973
+ test.equal(result.storage.limit, 500 * 1024, 'the limit that did parse is still reported');
10974
+ test.done();
10975
+ };
10976
+
10977
+ module.exports['Commands: status matches item names case-insensitively without reaching the prototype'] = async test => {
10978
+ // The item name is server-controlled and is used as a lookup key. Uppercasing it before the
10979
+ // lookup is what keeps a name like "constructor" from resolving to an inherited member, so
10980
+ // the matching has to stay case-insensitive AND prototype-safe at the same time.
10981
+ const connection = createMockConnection({
10982
+ state: 2,
10983
+ exec: async (cmd, attrs, opts) => {
10984
+ if (opts && opts.untagged && opts.untagged.STATUS) {
10985
+ await opts.untagged.STATUS({
10986
+ attributes: [
10987
+ { value: 'INBOX' },
10988
+ [
10989
+ { value: 'constructor' },
10990
+ { value: '1' },
10991
+ { value: '__proto__' },
10992
+ { value: '2' },
10993
+ { value: 'toString' },
10994
+ { value: '3' },
10995
+ { value: 'messages' }, // lowercase: servers send uppercase, but be liberal
10996
+ { value: '10' }
10997
+ ]
10998
+ ]
10999
+ });
11000
+ }
11001
+ return { next: () => {} };
11002
+ }
11003
+ });
11004
+
11005
+ const result = await statusCommand(connection, 'INBOX', { messages: true });
11006
+ test.equal(result.messages, 10, 'a lowercase item name must still be recognized');
11007
+ test.equal(Object.getPrototypeOf(result), Object.prototype, 'the status object must keep its prototype');
11008
+ test.deepEqual(Object.keys(result).sort(), ['messages', 'path'], 'no prototype-chain name may become a field');
11009
+ test.done();
11010
+ };
11011
+
11012
+ module.exports['Commands: status does not touch live mailbox state for another mailbox'] = async test => {
11013
+ // The updaters exist to keep the selected mailbox current. Running them for a STATUS of a
11014
+ // different mailbox would overwrite exists/uidNext/highestModseq with another folder's counts.
11015
+ let emitted = [];
11016
+ const connection = createMockConnection({
11017
+ state: 3,
11018
+ mailbox: { path: 'INBOX', exists: 5, uidNext: 100, highestModseq: 7n },
11019
+ emit: (name, payload) => emitted.push([name, payload]),
11020
+ exec: async (cmd, attrs, opts) => {
11021
+ if (opts && opts.untagged && opts.untagged.STATUS) {
11022
+ await opts.untagged.STATUS({
11023
+ attributes: [
11024
+ { value: 'Archive' },
11025
+ [{ value: 'MESSAGES' }, { value: '999' }, { value: 'UIDNEXT' }, { value: '888' }, { value: 'HIGHESTMODSEQ' }, { value: '777' }]
11026
+ ]
11027
+ });
11028
+ }
11029
+ return { next: () => {} };
11030
+ }
11031
+ });
11032
+
11033
+ const result = await statusCommand(connection, 'Archive', { messages: true, uidNext: true, highestModseq: true });
11034
+ test.equal(result.messages, 999, 'the queried mailbox is still reported');
11035
+ test.equal(connection.mailbox.exists, 5, 'the selected mailbox count must be untouched');
11036
+ test.equal(connection.mailbox.uidNext, 100);
11037
+ test.equal(connection.mailbox.highestModseq, 7n);
11038
+ test.equal(emitted.filter(entry => entry[0] === 'exists').length, 0, 'no exists event for another mailbox');
11039
+ test.done();
11040
+ };
11041
+
11042
+ module.exports['Commands: search drops unusable ESEARCH COUNT, MIN and MAX values'] = async test => {
11043
+ // isNaN() passes '1e400' (Infinity) and '-1'; a COUNT of Infinity or a negative MIN is not a
11044
+ // usable answer and must not reach the caller
11045
+ const connection = createMockConnection({
11046
+ state: 3,
11047
+ capabilities: new Map([['ESEARCH', true]]),
11048
+ exec: async (cmd, attrs, opts) => {
11049
+ if (opts && opts.untagged && opts.untagged.ESEARCH) {
11050
+ await opts.untagged.ESEARCH({
11051
+ attributes: [
11052
+ [{ type: 'ATOM', value: 'TAG' }, { value: 'A1' }],
11053
+ { type: 'ATOM', value: 'COUNT' },
11054
+ { value: '1e400' },
11055
+ { type: 'ATOM', value: 'MIN' },
11056
+ { value: '-1' },
11057
+ { type: 'ATOM', value: 'MAX' },
11058
+ { value: '42' }
11059
+ ]
11060
+ });
11061
+ }
11062
+ return { next: () => {} };
11063
+ }
11064
+ });
11065
+
11066
+ const result = await searchCommand(connection, { all: true }, { returnOptions: ['COUNT', 'MIN', 'MAX'] });
11067
+ test.equal(result.count, undefined, 'an overflowing COUNT must be dropped');
11068
+ test.equal(result.min, undefined, 'a negative MIN must be dropped');
11069
+ test.equal(result.max, 42, 'a usable MAX is still reported');
11070
+ test.done();
11071
+ };
11072
+
11073
+ module.exports['Commands: quota drops unusable resource values'] = async test => {
11074
+ // isNaN() passes '1e5' and ' 12 '; neither is a usable octet count
11075
+ const connection = createMockConnection({
11076
+ state: 2,
11077
+ capabilities: new Map([['QUOTA', true]]),
11078
+ exec: async (cmd, attrs, opts) => {
11079
+ if (cmd === 'GETQUOTAROOT' && opts && opts.untagged && opts.untagged.QUOTA) {
11080
+ await opts.untagged.QUOTA({
11081
+ attributes: [
11082
+ { value: 'root' },
11083
+ [{ value: 'STORAGE' }, { value: '1e5' }, { value: '500' }, { value: 'MESSAGE' }, { value: '10' }, { value: '20' }]
11084
+ ]
11085
+ });
11086
+ }
11087
+ return { next: () => {} };
11088
+ }
11089
+ });
11090
+
11091
+ const result = await quotaCommand(connection, 'INBOX');
11092
+ test.ok(result, 'the command must actually run');
11093
+ test.equal(result.storage.usage, undefined, 'an unusable usage must not be recorded');
11094
+ test.equal(result.message.usage, 10, 'a usable resource is still reported');
11095
+ test.equal(result.message.limit, 20);
11096
+ test.done();
11097
+ };
11098
+
11099
+ module.exports['Commands: list does not invent a special-use from a prototype-named mailbox'] = async test => {
11100
+ // The special-use hint map is keyed by server-supplied mailbox paths. On a plain object a
11101
+ // mailbox literally named "constructor" resolves to Object.prototype.constructor - truthy -
11102
+ // and the client would attach a special-use flag the server never sent.
11103
+ const connection = createMockConnection({
11104
+ state: 3,
11105
+ exec: async (cmd, attrs, opts) => {
11106
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
11107
+ for (let path of ['constructor', 'toString', '__proto__']) {
11108
+ await opts.untagged.LIST({
11109
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: path }]
11110
+ });
11111
+ }
11112
+ }
11113
+ return { next: () => {} };
11114
+ }
11115
+ });
11116
+
11117
+ const result = await listCommand(connection, '', '*', {});
11118
+ for (let path of ['constructor', 'toString', '__proto__']) {
11119
+ const entry = result.find(item => item.path === path);
11120
+ test.ok(entry, `${path} must still be listed`);
11121
+ test.equal(entry.specialUse, undefined, `${path} must not gain a special-use flag`);
11122
+ }
11123
+ test.done();
11124
+ };