imapflow 1.6.4 → 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.
Files changed (46) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/lib/commands/append.js +26 -4
  4. package/lib/commands/compress.js +29 -18
  5. package/lib/commands/copyuid-parser.js +4 -2
  6. package/lib/commands/expunge.js +5 -2
  7. package/lib/commands/fetch.js +9 -3
  8. package/lib/commands/list.js +18 -38
  9. package/lib/commands/namespace.js +2 -2
  10. package/lib/commands/quota.js +10 -2
  11. package/lib/commands/search.js +54 -14
  12. package/lib/commands/select.js +81 -70
  13. package/lib/commands/status-fields.js +68 -0
  14. package/lib/commands/status.js +23 -61
  15. package/lib/handler/imap-compiler.js +91 -60
  16. package/lib/handler/imap-parser.js +7 -0
  17. package/lib/handler/imap-stream.js +78 -12
  18. package/lib/handler/limits.js +16 -4
  19. package/lib/imap-flow.d.ts +22 -2
  20. package/lib/imap-flow.js +209 -94
  21. package/lib/jp-decoder.js +30 -5
  22. package/lib/limited-passthrough.js +19 -1
  23. package/lib/search-compiler.js +24 -16
  24. package/lib/tools.js +190 -39
  25. package/package.json +4 -4
  26. package/test/commands-branches-test.js +4 -0
  27. package/test/commands-integration-test.js +780 -5
  28. package/test/copyuid-parser-test.js +20 -0
  29. package/test/idle-polling-test.js +81 -0
  30. package/test/imap-compiler-test.js +74 -4
  31. package/test/imap-flow-coverage-test.js +4 -2
  32. package/test/imap-flow-fetch-download-test.js +26 -0
  33. package/test/imap-flow-internals-test.js +134 -0
  34. package/test/imap-flow-methods-test.js +92 -0
  35. package/test/imap-flow-secure-test.js +133 -116
  36. package/test/imap-flow-server-test.js +126 -0
  37. package/test/imap-parser-test.js +25 -0
  38. package/test/imap-stream-edge-cases-test.js +163 -3
  39. package/test/integration/rev2-live-test.js +30 -0
  40. package/test/jp-decoder-test.js +57 -0
  41. package/test/limited-passthrough-test.js +24 -0
  42. package/test/parser-limits-test.js +18 -0
  43. package/test/reliability-improvements-test.js +3 -3
  44. package/test/search-compiler-test.js +90 -3
  45. package/test/timer-policy-test.js +27 -1
  46. package/test/tools-test.js +151 -2
@@ -151,3 +151,23 @@ module.exports['CopyUID Parser: uidValidity zero is stored as BigInt(0)'] = test
151
151
  test.equal(map.uidMap.get(1), 100);
152
152
  test.done();
153
153
  };
154
+
155
+ // ============================================
156
+ // Hostile server input
157
+ // ============================================
158
+
159
+ module.exports['CopyUID Parser: non-decimal uidValidity is ignored, uidMap still parsed'] = test => {
160
+ // isNaN() passed values like "1e5" and "Infinity" through, and BigInt() then threw,
161
+ // losing the whole COPYUID result
162
+ let map = {};
163
+ parseCopyUid(makeResponse([{ value: 'COPYUID' }, { value: '1e5' }, { value: '1' }, { value: '100' }]), map);
164
+ test.equal(map.uidValidity, undefined);
165
+ test.ok(map.uidMap instanceof Map);
166
+ test.equal(map.uidMap.get(1), 100);
167
+
168
+ let map2 = {};
169
+ parseCopyUid(makeResponse([{ value: 'COPYUID' }, { value: 'Infinity' }, { value: '1' }, { value: '100' }]), map2);
170
+ test.equal(map2.uidValidity, undefined);
171
+ test.equal(map2.uidMap.get(1), 100);
172
+ test.done();
173
+ };
@@ -347,3 +347,84 @@ module.exports['Polling: no polling session without a saved select command'] = a
347
347
  test.equal(connection.idling, false, 'idling untouched');
348
348
  test.done();
349
349
  };
350
+
351
+ module.exports['Polling: a falsy STATUS result stops the loop as PollFailed'] = async test => {
352
+ await withFakeTimers(async timers => {
353
+ let warnings = [];
354
+ let connection = createConnection({
355
+ missingIdleCommand: 'STATUS',
356
+ // Only STATUS is ever polled; a failure without a NO status makes the
357
+ // real STATUS implementation swallow the error and return false
358
+ respond: async () => {
359
+ throw new Error('Command failed');
360
+ }
361
+ });
362
+ connection.log.warn = entry => warnings.push(entry);
363
+
364
+ let idlePromise = idleCommand(connection, 60000);
365
+ await timers.drain();
366
+ await idlePromise;
367
+
368
+ test.ok(
369
+ warnings.some(entry => entry && entry.err && entry.err.code === 'PollFailed'),
370
+ 'the falsy STATUS result surfaced as a PollFailed error'
371
+ );
372
+ test.equal(connection.idling, false, 'idling reset after the failed poll');
373
+ test.equal(connection.preCheck, false, 'preCheck released');
374
+ test.equal(timers.count(), 0, 'polling stopped');
375
+ test.done();
376
+ });
377
+ };
378
+
379
+ module.exports['Polling: a repeated break call is a no-op'] = async test => {
380
+ await withFakeTimers(async timers => {
381
+ let connection = createConnection();
382
+
383
+ let idlePromise = idleCommand(connection, 60000);
384
+ await timers.drain();
385
+ test.deepEqual(connection.commands, ['NOOP'], 'the immediate first poll ran');
386
+
387
+ // A caller may hold on to the break function and invoke it more than once
388
+ let preCheck = connection.preCheck;
389
+ await preCheck();
390
+ await preCheck();
391
+ await idlePromise;
392
+
393
+ test.equal(connection.idling, false, 'idling reset');
394
+ test.equal(connection.preCheck, false, 'preCheck released');
395
+ test.equal(timers.count(), 0, 'no timer left armed');
396
+
397
+ await timers.fire();
398
+ test.deepEqual(connection.commands, ['NOOP'], 'no further poll after the duplicate break');
399
+ test.done();
400
+ });
401
+ };
402
+
403
+ module.exports['Polling: a break in the initiation tick prevents the first poll'] = async test => {
404
+ await withFakeTimers(async timers => {
405
+ let connection = createConnection();
406
+ // Simulate a break request (e.g. a command being queued) landing in the same
407
+ // tick the loop is initiated: trap the loop installing its own preCheck and
408
+ // break through it immediately, before the first poll has started
409
+ let installedPreCheck = connection.preCheck;
410
+ Object.defineProperty(connection, 'preCheck', {
411
+ get: () => installedPreCheck,
412
+ set: value => {
413
+ installedPreCheck = value;
414
+ if (typeof value === 'function') {
415
+ value();
416
+ }
417
+ }
418
+ });
419
+
420
+ let idlePromise = idleCommand(connection, 60000);
421
+ await timers.drain();
422
+ await idlePromise;
423
+
424
+ test.deepEqual(connection.commands, [], 'the already-cancelled session never polled');
425
+ test.equal(connection.idling, false, 'idling reset');
426
+ test.equal(connection.preCheck, false, 'preCheck released');
427
+ test.equal(timers.count(), 0, 'no timer left armed');
428
+ test.done();
429
+ });
430
+ };
@@ -642,10 +642,11 @@ module.exports['IMAP Compiler: partial range in SECTION'] = test =>
642
642
 
643
643
  // Returns the error a compile attempt raised, or undefined when it succeeded.
644
644
  // Several cases below assert that a token can never reach the wire, and the
645
- // try/catch is the only interesting part of each.
646
- const compileError = async attributes => {
645
+ // try/catch is the only interesting part of each. Takes either an attributes
646
+ // array (compiled under a default tag/command) or a full response object.
647
+ const compileError = async input => {
647
648
  try {
648
- await compiler({ tag: 'A', command: 'CMD', attributes });
649
+ await compiler(Array.isArray(input) ? { tag: 'A', command: 'CMD', attributes: input } : input);
649
650
  } catch (err) {
650
651
  return err;
651
652
  }
@@ -663,10 +664,50 @@ module.exports['IMAP Compiler: SEQUENCE rejects values that are not sequence set
663
664
 
664
665
  module.exports['IMAP Compiler: SEQUENCE accepts every valid sequence-set form'] = test =>
665
666
  asyncWrapper(test, async test => {
666
- for (let value of ['1', '*', '1:*', '*:1', '1,3,5', '1:3,7,9:*', '4294967295']) {
667
+ // '$' is the RFC 5182 SEARCHRES saved-result marker, valid as the entire set
668
+ for (let value of ['1', '*', '1:*', '*:1', '1,3,5', '1:3,7,9:*', '4294967295', '$']) {
667
669
  const compiled = (await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value }] })).toString();
668
670
  test.equal(compiled, `A FETCH ${value}`);
669
671
  }
672
+
673
+ // '$' only stands for the whole set, never an element of one
674
+ for (let value of ['$,1', '1,$', '$:2']) {
675
+ let err = await compileError([{ type: 'SEQUENCE', value }]);
676
+ test.equal(err && err.code, 'InvalidSequenceSet', `${JSON.stringify(value)} must be rejected`);
677
+ }
678
+ });
679
+
680
+ module.exports['IMAP Compiler: SEQUENCE validation stays linear on huge valid sets'] = test =>
681
+ asyncWrapper(test, async test => {
682
+ // The regex this replaced overflowed the engine's backtrack stack with an
683
+ // uncoded RangeError at roughly a million comma-separated elements - a set
684
+ // a fetch over a large mailbox can legitimately produce
685
+ const huge = Array.from({ length: 1500000 }, (_, i) => i + 1).join(',');
686
+ const compiled = await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value: huge }] });
687
+ test.ok(compiled.toString().endsWith(huge), 'the huge but valid set must compile');
688
+ });
689
+
690
+ module.exports['IMAP Compiler: SEQUENCE validation is skipped when logging'] = test =>
691
+ asyncWrapper(test, async test => {
692
+ // The incoming token parser accepts sequence-shaped tokens the strict grammar
693
+ // rejects (an ESEARCH set like "1:2:3", a folder name like "12:30:00"). Every
694
+ // parsed server response is re-compiled for the log, so a throw here would let
695
+ // one quirky server line kill the whole connection.
696
+ for (let value of ['1:2:3', '12:30:00', '1,2:3:4']) {
697
+ const compiled = (await compiler({ tag: '*', command: 'OK', attributes: [{ type: 'SEQUENCE', value }] }, { isLogging: true })).toString();
698
+ test.equal(compiled, `* OK ${value}`, 'logging re-compile must pass the token through');
699
+ }
700
+ });
701
+
702
+ module.exports['IMAP Compiler: tag and command cannot carry a line terminator'] = test =>
703
+ asyncWrapper(test, async test => {
704
+ // The tag/command preamble is written verbatim ahead of any token, so it goes
705
+ // through the same choke point as everything else
706
+ let err = await compileError({ tag: 'A\r\nZZ LOGOUT', command: 'NOOP' });
707
+ test.equal(err && err.code, 'InvalidTokenValue', 'CRLF in the tag must be rejected');
708
+
709
+ err = await compileError({ tag: 'A', command: 'NOOP\r\nZZ LOGOUT' });
710
+ test.equal(err && err.code, 'InvalidTokenValue', 'CRLF in the command must be rejected');
670
711
  });
671
712
 
672
713
  module.exports['IMAP Compiler: quoted strings use IMAP escaping, not JSON escaping'] = test =>
@@ -707,6 +748,23 @@ module.exports['IMAP Compiler: NUMBER coerces its value instead of writing it th
707
748
  test.equal((await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value: '42' }] })).toString(), 'A CMD 42');
708
749
  });
709
750
 
751
+ module.exports['IMAP Compiler: numeric tokens clamp to bounded non-negative integers'] = test =>
752
+ asyncWrapper(test, async test => {
753
+ // Infinity, negatives and unsafe magnitudes would otherwise emit bytes outside
754
+ // the number alphabet ("Infinity", "-5", "1e+300") and get a server BAD blamed
755
+ // on the server instead of the caller
756
+ for (let [value, expected] of [
757
+ [Infinity, '0'],
758
+ ['Infinity', '0'],
759
+ [-5, '0'],
760
+ ['2e21', '0'],
761
+ [4.6, '5']
762
+ ]) {
763
+ const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value }] })).toString();
764
+ test.equal(compiled, `A CMD ${expected}`, `${JSON.stringify(value)} must clamp to ${expected}`);
765
+ }
766
+ });
767
+
710
768
  module.exports['IMAP Compiler: partial range coerces its elements'] = test =>
711
769
  asyncWrapper(test, async test => {
712
770
  // The partial range is the last token component written verbatim, so it is
@@ -719,6 +777,18 @@ module.exports['IMAP Compiler: partial range coerces its elements'] = test =>
719
777
  await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 1024] }] })
720
778
  ).toString();
721
779
  test.equal(normal, 'A FETCH BODY.PEEK[]<0.1024>');
780
+
781
+ // Floats and unbounded numerics clamp the same way NUMBER tokens do, so no
782
+ // non-digit byte ("512.5", "Infinity") can appear inside the partial range
783
+ const float = (
784
+ await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 512.5] }] })
785
+ ).toString();
786
+ test.equal(float, 'A FETCH BODY.PEEK[]<0.513>');
787
+
788
+ const unbounded = (
789
+ await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: ['Infinity', '2e21'] }] })
790
+ ).toString();
791
+ test.equal(unbounded, 'A FETCH BODY.PEEK[]<0.0>');
722
792
  });
723
793
 
724
794
  module.exports['IMAP Compiler: logging output never throws on unsendable values'] = test =>
@@ -591,9 +591,11 @@ module.exports['Coverage: reader caps very large throttle backoff'] = async test
591
591
  let readerPromise = runReaderWith(client, ['A1 BAD Request is throttled. Suggested Backoff Time: 999999999 milliseconds']);
592
592
  // give the reader a tick to register the throttle timer, then abort it
593
593
  await drain();
594
- if (typeof client._throttleAbort === 'function') {
595
- client._throttleAbort(true);
594
+ for (let entry of client._throttleWaits) {
595
+ clearTimeout(entry.timer);
596
+ entry.resolve(true);
596
597
  }
598
+ client._throttleWaits.clear();
597
599
  await readerPromise;
598
600
  test.ok(rejected);
599
601
  test.done();
@@ -852,3 +852,29 @@ module.exports['DownloadMany: decodes base64 and quoted-printable parts'] = asyn
852
852
  test.equal(res['3'].meta.disposition, 'attachment');
853
853
  test.done();
854
854
  };
855
+
856
+ module.exports['Download: a buffering transform cannot defeat maxBytes'] = async test => {
857
+ // format=flowed unwrapping and the Japanese charset decoder both hold their whole input
858
+ // before emitting anything, so the limiter at the tail of the pipeline reports
859
+ // `limited === false` however much the server sends. Without a bound of their own the fetch
860
+ // loop would keep pulling the entire part.
861
+ for (let contentType of ['text/plain; format=flowed', 'text/plain; charset=iso-2022-jp']) {
862
+ let client = makeClient();
863
+ let mime = Buffer.from(`Content-Type: ${contentType}\r\nContent-Disposition: inline\r\n\r\n`);
864
+ let calls = 0;
865
+ client.fetchOne = async () => {
866
+ calls++;
867
+ let bodyParts = new Map();
868
+ bodyParts.set('2.mime', mime);
869
+ // full-size chunks keep hasMore true; the loop must stop on its own, not run dry
870
+ bodyParts.set('2', calls <= 40 ? Buffer.alloc(1024, 0x61) : Buffer.alloc(1));
871
+ return { uid: 1, size: 1024 * 1024, bodyParts };
872
+ };
873
+
874
+ let { content } = await client.download('1', '2', { chunkSize: 1024, maxBytes: 512 });
875
+ let data = await collect(content);
876
+ test.ok(data.length <= 512, `${contentType}: delivered ${data.length} bytes, expected no more than 512`);
877
+ test.ok(calls <= 3, `${contentType}: the fetch loop must stop at the bound, made ${calls} calls`);
878
+ }
879
+ test.done();
880
+ };
@@ -471,3 +471,137 @@ module.exports['Internals: untaggedFetch includes modseq and flagColor'] = async
471
471
  test.equal(evt.flagColor, 'red');
472
472
  test.done();
473
473
  };
474
+
475
+ // ============================================================================
476
+ // runInternal dispatch guards
477
+ // ============================================================================
478
+
479
+ module.exports['Internals: runInternal returns false for unknown command'] = async test => {
480
+ let client = makeClient();
481
+ client.socket = { destroyed: false };
482
+ let res = await client.runInternal('NOT_A_COMMAND');
483
+ test.equal(res, false);
484
+ test.done();
485
+ };
486
+
487
+ module.exports['Internals: runInternal throws NoConnection when the socket is destroyed'] = async test => {
488
+ let client = makeClient();
489
+ client.socket = { destroyed: true };
490
+ let err = null;
491
+ try {
492
+ await client.runInternal('NOOP');
493
+ } catch (e) {
494
+ err = e;
495
+ }
496
+ test.ok(err);
497
+ test.equal(err.code, 'NoConnection');
498
+ test.done();
499
+ };
500
+
501
+ // ============================================================================
502
+ // send() onSend error containment
503
+ // ============================================================================
504
+
505
+ module.exports['Internals: send contains a throwing onSend callback'] = async test => {
506
+ let client = makeClient();
507
+ let written = [];
508
+ let warnings = [];
509
+ client.socket = { destroyed: false };
510
+ client.writeSocket = { destroyed: false, write: chunk => written.push(chunk) };
511
+ client.state = client.states.AUTHENTICATED;
512
+ client.log.warn = entry => warnings.push(entry);
513
+ client.currentRequest = { tag: 'A1', sent: false };
514
+
515
+ // The command is already on the wire when onSend runs, so a throwing callback
516
+ // must be logged and swallowed instead of rejecting the request
517
+ await client.send({
518
+ tag: 'A1',
519
+ command: 'NOOP',
520
+ attributes: [],
521
+ options: {
522
+ onSend: () => {
523
+ throw new Error('onSend boom');
524
+ }
525
+ }
526
+ });
527
+
528
+ test.equal(written.length, 1, 'the command was written to the socket');
529
+ test.equal(written[0].toString(), 'A1 NOOP\r\n', 'the actual wire bytes went out before onSend ran');
530
+ test.equal(client.currentRequest.sent, true, 'the request is marked as sent');
531
+ test.ok(
532
+ warnings.some(entry => entry && entry.err && entry.err.message === 'onSend boom'),
533
+ 'the callback error was logged'
534
+ );
535
+ test.done();
536
+ };
537
+
538
+ // ============================================================================
539
+ // countUnknownTag teardown guard
540
+ // ============================================================================
541
+
542
+ module.exports['Internals: countUnknownTag ignores tags on a closed connection'] = test => {
543
+ let client = makeClient();
544
+ client.isClosed = true;
545
+ client.countUnknownTag('A1');
546
+ test.equal(client._unknownTagCount, 0, 'teardown crossover is not counted');
547
+ test.done();
548
+ };
549
+
550
+ // ============================================================================
551
+ // rejectUnparsedCompletion
552
+ // ============================================================================
553
+
554
+ // Installs a command that is current and on the wire, returning a holder for
555
+ // the rejection the reader loop would deliver to the caller
556
+ const armInFlight = (client, tag) => {
557
+ let captured = { err: null };
558
+ client.currentRequest = { tag, sent: true };
559
+ client.requestTagMap.set(tag, {
560
+ tag,
561
+ reject: err => {
562
+ captured.err = err;
563
+ }
564
+ });
565
+ return captured;
566
+ };
567
+
568
+ module.exports['Internals: rejectUnparsedCompletion ignores lines without an in-flight command'] = test => {
569
+ let client = makeClient();
570
+
571
+ client.currentRequest = false;
572
+ client.rejectUnparsedCompletion(Buffer.from('A1 OK done'), new Error('parse fail'));
573
+ test.equal(client.currentRequest, false, 'no request to settle');
574
+
575
+ // A command that is current but not yet on the wire must not be settled either
576
+ client.currentRequest = { tag: 'A1', sent: false };
577
+ client.rejectUnparsedCompletion(Buffer.from('A1 OK done'), new Error('parse fail'));
578
+ test.ok(client.currentRequest, 'the unsent request is untouched');
579
+ test.done();
580
+ };
581
+
582
+ module.exports['Internals: rejectUnparsedCompletion recovers the tag from NUL-padded raw bytes'] = async test => {
583
+ let client = makeClient();
584
+ let captured = armInFlight(client, 'A1');
585
+
586
+ // The parser died before extracting a tag - the raw line carries the buggy-server
587
+ // NUL padding, so the fallback must skip it and stop at the first non-tag byte
588
+ let parserError = new Error('parse fail');
589
+ client.rejectUnparsedCompletion(Buffer.from('\x00\x00A1 \x07garbage'), parserError);
590
+
591
+ test.ok(captured.err, 'the in-flight command was failed');
592
+ test.equal(captured.err.code, 'ParserError');
593
+ test.equal(captured.err.parserError, parserError);
594
+ test.equal(client.currentRequest, false, 'the request slot was cleared');
595
+ test.done();
596
+ };
597
+
598
+ module.exports['Internals: rejectUnparsedCompletion ignores a mismatched raw tag'] = test => {
599
+ let client = makeClient();
600
+ let captured = armInFlight(client, 'A1');
601
+
602
+ client.rejectUnparsedCompletion(Buffer.from('A2 NO other'), new Error('parse fail'));
603
+
604
+ test.equal(captured.err, null, 'a line for another tag settles nothing');
605
+ test.ok(client.currentRequest, 'the in-flight request stays current');
606
+ test.done();
607
+ };
@@ -795,3 +795,95 @@ module.exports['Methods: autoEnable does not retry when disableIMAP4rev2 already
795
795
  test.equal(calls.length, 1);
796
796
  test.done();
797
797
  };
798
+
799
+ module.exports['Methods: untaggedExpunge refuses an unusable sequence number'] = async test => {
800
+ // Same bound untaggedExists() applies: an overflowing or non-decimal value must not
801
+ // decrement the live message count
802
+ let client = makeClient();
803
+ client.mailbox.exists = 10;
804
+ let events = [];
805
+ client.on('expunge', e => events.push(e));
806
+
807
+ await client.untaggedExpunge(false);
808
+ for (let bad of ['9'.repeat(400), '1e5', '0', 'abc', '']) {
809
+ await client.untaggedExpunge({ command: bad });
810
+ }
811
+ test.equal(client.mailbox.exists, 10, 'the live message count must be left alone');
812
+ test.equal(events.length, 0);
813
+
814
+ await client.untaggedExpunge({ command: '3' });
815
+ test.equal(client.mailbox.exists, 9, 'a usable sequence number is still applied');
816
+ test.equal(events.length, 1);
817
+ test.equal(events[0].seq, 3);
818
+ test.done();
819
+ };
820
+
821
+ module.exports['Methods: untaggedVanished survives a malformed response'] = async test => {
822
+ // "* VANISHED (EARLIER)" leaves no sequence set at all. Throwing here would abort the
823
+ // handler for the whole response, and handleResponse only warns - so every expunge the
824
+ // response did carry would be dropped and the client's mailbox view would go stale.
825
+ let client = makeClient();
826
+ let events = [];
827
+ client.on('expunge', e => events.push(e));
828
+
829
+ await client.untaggedVanished({ attributes: [[{ value: 'EARLIER' }]] });
830
+ await client.untaggedVanished({ attributes: [] });
831
+ await client.untaggedVanished({});
832
+ await client.untaggedVanished({ attributes: [[{ value: 'EARLIER' }], null] });
833
+
834
+ test.equal(events.length, 0, 'nothing to report, and nothing thrown');
835
+
836
+ // a well-formed response still works after the malformed ones
837
+ await client.untaggedVanished({ attributes: [[{ value: 'EARLIER' }], { value: '5:6' }] });
838
+ test.equal(events.length, 2);
839
+ test.equal(events[0].uid, 5);
840
+ test.equal(events[0].earlier, true);
841
+ test.done();
842
+ };
843
+
844
+ module.exports['Methods: untaggedExists refuses an overflowing count'] = async test => {
845
+ // Number('9'.repeat(400)) is Infinity: resolveRange('*') would then compile the literal
846
+ // string "Infinity" and every range-based command would fail until the next SELECT
847
+ let client = makeClient();
848
+ client.mailbox = { path: 'INBOX', exists: 5 };
849
+ let events = [];
850
+ client.on('exists', e => events.push(e));
851
+
852
+ await client.untaggedExists(false);
853
+ await client.untaggedExists({});
854
+ for (let bad of ['9'.repeat(400), '1e5', '-1', 'abc', '1.5', '']) {
855
+ await client.untaggedExists({ command: bad });
856
+ }
857
+ test.equal(client.mailbox.exists, 5, 'the live message count must be left alone');
858
+ test.equal(events.length, 0);
859
+
860
+ await client.untaggedExists({ command: '7' });
861
+ test.equal(client.mailbox.exists, 7, 'a usable count is still applied');
862
+ test.equal(events.length, 1);
863
+ test.done();
864
+ };
865
+
866
+ module.exports['Methods: throttleWait caps the delay and close() aborts it'] = async test => {
867
+ let client = makeClient();
868
+
869
+ // A server hint is unbounded; the wait may never outlive the client either
870
+ let started = Date.now();
871
+ let pending = client.throttleWait(7 * 24 * 3600 * 1000);
872
+ test.equal(client._throttleWaits.size, 1, 'the wait is tracked so close() can reach it');
873
+
874
+ client.close();
875
+ test.equal(await pending, true, 'an aborted wait reports true');
876
+ test.equal(client._throttleWaits.size, 0, 'the tracked wait is released');
877
+ test.ok(Date.now() - started < 5000, 'close() must not wait out the delay');
878
+
879
+ // normal expiry reports false and releases the entry. The back-off timer is deliberately
880
+ // unref'd, so the test has to hold the event loop open itself - in a real session the
881
+ // socket does that.
882
+ let client2 = makeClient();
883
+ let keepAlive = setTimeout(() => {}, 1000);
884
+ test.equal(await client2.throttleWait(1), false);
885
+ clearTimeout(keepAlive);
886
+ test.equal(client2._throttleWaits.size, 0);
887
+ client2.close();
888
+ test.done();
889
+ };