imapflow 1.6.3 → 1.6.5

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/lib/imap-flow.js CHANGED
@@ -643,22 +643,40 @@ class ImapFlow extends EventEmitter {
643
643
  }
644
644
 
645
645
  if (typeof options.onSend === 'function') {
646
- options.onSend();
646
+ // The command is already on the wire, so a throwing onSend callback must not
647
+ // reach trySend()'s catch - that would reject the request and dispatch the
648
+ // next command into the server's pending state for this one.
649
+ try {
650
+ options.onSend();
651
+ } catch (err) {
652
+ this.log.warn({ err, cid: this.id });
653
+ }
647
654
  }
648
655
  }
649
656
 
650
657
  async trySend() {
651
- if (this.currentRequest || !this.requestQueue.length) {
652
- return;
653
- }
654
- this.currentRequest = this.requestQueue.shift();
658
+ while (!this.currentRequest && this.requestQueue.length) {
659
+ this.currentRequest = this.requestQueue.shift();
655
660
 
656
- await this.send({
657
- tag: this.currentRequest.tag,
658
- command: this.currentRequest.command,
659
- attributes: this.currentRequest.attributes,
660
- options: this.currentRequest.options
661
- });
661
+ try {
662
+ await this.send({
663
+ tag: this.currentRequest.tag,
664
+ command: this.currentRequest.command,
665
+ attributes: this.currentRequest.attributes,
666
+ options: this.currentRequest.options
667
+ });
668
+ return;
669
+ } catch (err) {
670
+ // A failure here (most likely the compiler refusing an invalid
671
+ // user-supplied value) belongs to the command that was being dispatched.
672
+ // Without this the shifted request would stay currentRequest forever:
673
+ // nothing reached the wire, so no tagged response ever clears it, and
674
+ // every later command would queue behind it until the socket timeout.
675
+ // Reject the failed command and keep draining the queue.
676
+ this.commandParts = [];
677
+ this.rejectCurrentRequest(err);
678
+ }
679
+ }
662
680
  }
663
681
 
664
682
  exec(command, attributes, options) {
@@ -685,10 +703,10 @@ class ImapFlow extends EventEmitter {
685
703
  let promise = new Promise((resolve, reject) => {
686
704
  this.requestTagMap.set(tag, { command, attributes, options, resolve, reject });
687
705
  this.requestQueue.push({ tag, command, attributes, options });
688
- this.trySend().catch(err => {
689
- this.requestTagMap.delete(tag);
690
- reject(err);
691
- });
706
+ // trySend() settles dispatch failures itself, by rejecting the affected
707
+ // command through requestTagMap; this catch exists only so a throw from the
708
+ // dispatch machinery itself can never surface as a floating rejection.
709
+ this.trySend().catch(err => this.log.warn({ err, cid: this.id }));
692
710
  });
693
711
 
694
712
  // Prevent unhandled promise rejection if close() rejects this request
@@ -834,6 +852,42 @@ class ImapFlow extends EventEmitter {
834
852
  }
835
853
  }
836
854
 
855
+ /**
856
+ * Fails the in-flight command when a line that could not be parsed was addressed to its tag.
857
+ * Only the leading tag is read from the raw payload - the rest of the line is by definition
858
+ * not trustworthy - and only the command that is actually on the wire may be settled this way,
859
+ * the same invariant the parsed tagged-response path enforces.
860
+ *
861
+ * @param {Buffer} payload - Raw bytes of the line that failed to parse.
862
+ * @param {Error} parserError - The error the parser raised.
863
+ */
864
+ rejectUnparsedCompletion(payload, parserError) {
865
+ if (!this.currentRequest || !this.currentRequest.sent) {
866
+ return;
867
+ }
868
+
869
+ // Prefer the tag the parser had already extracted before it failed - it went
870
+ // through the same leading-NUL workaround as every parsed response. Fall back
871
+ // to the raw bytes for lines whose tag itself was unparseable: skip the NUL
872
+ // padding buggy servers prepend and stop at the first byte a tag cannot contain.
873
+ let tag = parserError && parserError.parsedTag;
874
+ if (!tag) {
875
+ // eslint-disable-next-line no-control-regex
876
+ let match = payload.toString('latin1', 0, 64).match(/^\0*([^\s\x00-\x1f\x7f]+)/);
877
+ tag = match && match[1];
878
+ }
879
+ if (!tag || tag !== this.currentRequest.tag) {
880
+ return;
881
+ }
882
+
883
+ let err = new Error('Failed to parse the server response for this command');
884
+ err.code = parserError.code || 'ParserError';
885
+ err.parserError = parserError;
886
+ this.rejectCurrentRequest(err);
887
+
888
+ this.trySend().catch(sendErr => this.log.warn({ err: sendErr, cid: this.id }));
889
+ }
890
+
837
891
  /**
838
892
  * Handles a single parsed server response: telemetry, continuation requests, response-code
839
893
  * section handlers, untagged handlers and tagged command completion.
@@ -846,26 +900,42 @@ class ImapFlow extends EventEmitter {
846
900
 
847
901
  try {
848
902
  parsed = await parser(data.payload, { literals: data.literals });
849
- if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
850
- let payload = { response: parsed.command };
851
-
852
- if (
853
- parsed.attributes &&
854
- parsed.attributes[0] &&
855
- parsed.attributes[0].section &&
856
- parsed.attributes[0].section[0] &&
857
- parsed.attributes[0].section[0].type === 'ATOM'
858
- ) {
859
- payload.code = parsed.attributes[0].section[0].value;
860
- }
861
- this.emit('response', payload);
862
- }
863
903
  } catch (err) {
864
904
  // can not make sense of this
865
905
  this.log.error({ src: 's', msg: data.payload.toString(), err, cid: this.id });
906
+ // An unparseable untagged line is junk that can be skipped, but the line may
907
+ // have been the in-flight command's tagged completion. Dropping that one
908
+ // silently strands the command: currentRequest is never cleared, so trySend()
909
+ // stops dispatching and every later command queues behind it until the socket
910
+ // timeout fires. The tag is recovered from the raw bytes (a tag is
911
+ // ASTRING-CHAR only, so it survives whatever made the rest unparseable) and
912
+ // the command is failed with the parser error instead of hanging.
913
+ this.rejectUnparsedCompletion(data.payload, err);
866
914
  return true;
867
915
  }
868
916
 
917
+ if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
918
+ let payload = { response: parsed.command };
919
+
920
+ if (
921
+ parsed.attributes &&
922
+ parsed.attributes[0] &&
923
+ parsed.attributes[0].section &&
924
+ parsed.attributes[0].section[0] &&
925
+ parsed.attributes[0].section[0].type === 'ATOM'
926
+ ) {
927
+ payload.code = parsed.attributes[0].section[0].value;
928
+ }
929
+ // Outside the parse try/catch on purpose: a throwing user 'response' listener
930
+ // is not a parse failure and must not settle the in-flight command or fail the
931
+ // connection - the same contract untagged handlers get.
932
+ try {
933
+ this.emit('response', payload);
934
+ } catch (err) {
935
+ this.log.warn({ err, cid: this.id });
936
+ }
937
+ }
938
+
869
939
  let logCompiled = await compiler(parsed, {
870
940
  isLogging: true
871
941
  });
@@ -1629,13 +1699,15 @@ class ImapFlow extends EventEmitter {
1629
1699
  this.writeSocket = this.socket;
1630
1700
  });
1631
1701
 
1632
- if (upgraded && this.expectCapabilityUpdate) {
1633
- // After STARTTLS the server may advertise a different capability set
1634
- // (e.g., LOGINDISABLED removed, new AUTH= methods). Clear the pre-TLS
1635
- // map before re-fetching so stale capabilities cannot leak through
1636
- // if the CAPABILITY response is delayed or absent.
1637
- this.capabilities.clear();
1638
- this.authCapabilities.clear();
1702
+ if (upgraded) {
1703
+ // RFC 9051 section 6.2.1: once TLS is started the client MUST discard the
1704
+ // cached capabilities and reissue CAPABILITY, because everything learned
1705
+ // before the handshake was plaintext an active attacker could rewrite.
1706
+ // Unconditional on purpose: a server that stamps [CAPABILITY ...] on the
1707
+ // STARTTLS OK itself clears expectCapabilityUpdate, so keying the discard
1708
+ // on that flag would keep exactly the pre-TLS list an attacker controls -
1709
+ // the list that then picks the AUTH mechanism and answers LOGINDISABLED.
1710
+ this.clearCapabilities();
1639
1711
  await this.run('CAPABILITY');
1640
1712
  }
1641
1713
 
@@ -1780,6 +1852,17 @@ class ImapFlow extends EventEmitter {
1780
1852
  this.state = this.states.LOGOUT;
1781
1853
  }
1782
1854
 
1855
+ // Drops every capability-derived field together - the counterpart of
1856
+ // updateCapabilitiesFromRaw() below, which sets them together. rawCapabilities is
1857
+ // public surface external consumers read, so a discard (RFC 9051 6.2.1 requires
1858
+ // one after STARTTLS) that missed it would leave the stale list visible if the
1859
+ // re-fetch fails.
1860
+ clearCapabilities() {
1861
+ this.capabilities.clear();
1862
+ this.authCapabilities.clear();
1863
+ this.rawCapabilities = null;
1864
+ }
1865
+
1783
1866
  updateCapabilitiesFromRaw(rawCapabilities) {
1784
1867
  this.rawCapabilities = rawCapabilities;
1785
1868
  this.capabilities = updateCapabilities(rawCapabilities);
@@ -30,6 +30,17 @@ let setBoolOpt = (attributes, term, value) => {
30
30
  attributes.push({ type: 'ATOM', value: term.toUpperCase() });
31
31
  };
32
32
 
33
+ /**
34
+ * Normalizes a user-supplied sequence set (string, number, bigint, or an array of
35
+ * them) into the single string value of a SEQUENCE token. An array is one
36
+ * comma-joined set: separate tokens would be parsed by the server as extra
37
+ * sequence-number search keys ANDed to the query, not as part of the set.
38
+ *
39
+ * @param {*} value - The sequence set value(s)
40
+ * @returns {string} The joined sequence set string
41
+ */
42
+ let toSequenceValue = value => [].concat(value).join(',');
43
+
33
44
  /**
34
45
  * Adds a search option with its value(s) to the attributes array.
35
46
  * Handles NOT operations and array values.
@@ -37,23 +48,20 @@ let setBoolOpt = (attributes, term, value) => {
37
48
  * @param {Array} attributes - Array to append the attribute to
38
49
  * @param {string} term - The search term (e.g., 'FROM', 'SUBJECT')
39
50
  * @param {*} value - The value for the search term (string, array, or falsy for NOT)
40
- * @param {string} [type='ATOM'] - The attribute type
41
51
  */
42
- let setOpt = (attributes, term, value, type) => {
43
- type = type || 'ATOM';
44
-
52
+ let setOpt = (attributes, term, value) => {
45
53
  // Handle NOT operations for false or null values
46
54
  if (value === false || value === null) {
47
- attributes.push({ type, value: 'NOT' });
55
+ attributes.push({ type: 'ATOM', value: 'NOT' });
48
56
  }
49
57
 
50
- attributes.push({ type, value: term.toUpperCase() });
58
+ attributes.push({ type: 'ATOM', value: term.toUpperCase() });
51
59
 
52
- // Handle array values (e.g., multiple UIDs)
60
+ // Handle array values (e.g. HEADER name/value pairs)
53
61
  if (Array.isArray(value)) {
54
- value.forEach(entry => attributes.push({ type, value: (entry || '').toString() }));
62
+ value.forEach(entry => attributes.push({ type: 'ATOM', value: (entry || '').toString() }));
55
63
  } else {
56
- attributes.push({ type, value: value.toString() });
64
+ attributes.push({ type: 'ATOM', value: value.toString() });
57
65
  }
58
66
  };
59
67
 
@@ -158,12 +166,12 @@ module.exports.searchCompiler = (connection, query) => {
158
166
  // Custom sequence range support (non-standard)
159
167
  case 'SEQ':
160
168
  {
161
- let value = params[term];
162
- if (typeof value === 'number') {
163
- value = value.toString();
164
- }
165
- // Only accept valid sequence strings (no whitespace)
166
- if (typeof value === 'string' && /^\S+$/.test(value)) {
169
+ // Passed through as a SEQUENCE token: the compiler validates the
170
+ // set grammar and throws a coded error. An invalid value used to
171
+ // be dropped silently here, which turned a bad filter into an
172
+ // unrestricted search that matched every message.
173
+ let value = params[term] || params[term] === 0 ? toSequenceValue(params[term]) : '';
174
+ if (value) {
167
175
  attributes.push({ type: 'SEQUENCE', value });
168
176
  }
169
177
  }
@@ -233,10 +241,13 @@ module.exports.searchCompiler = (connection, query) => {
233
241
  }
234
242
  break;
235
243
 
236
- // UID sequences
244
+ // UID sequences. The key stays an ATOM and only the value is a
245
+ // SEQUENCE token, so the compiler validates the sequence set
246
+ // itself rather than the "UID" keyword in front of it.
237
247
  case 'UID':
238
248
  if (params[term]) {
239
- setOpt(attributes, term, params[term], 'SEQUENCE');
249
+ attributes.push({ type: 'ATOM', value: 'UID' });
250
+ attributes.push({ type: 'SEQUENCE', value: toSequenceValue(params[term]) });
240
251
  }
241
252
  break;
242
253
 
package/lib/tools.js CHANGED
@@ -20,9 +20,13 @@ const EXPANDED_RANGE_LIMIT = 0x1000000;
20
20
  // When IMAP4rev2 is active, these are available even without their own capability
21
21
  // token. BINARY is deliberately excluded - RFC 9051 only folds in the FETCH side,
22
22
  // which fetch.js handles with its own isRev2Active check, while the APPEND side
23
- // stays gated on the BINARY token. The set mirrors the Appendix E list in full,
24
- // including entries no call site consults yet, so any future capability check
25
- // gets the rev2 folding for free.
23
+ // stays gated on the BINARY token. SPECIAL-USE is a partial fold: Appendix E only
24
+ // folds in the special-use mailbox attributes, not the RFC 6154 LIST selection and
25
+ // RETURN options - the only call sites that act on this entry are in list.js,
26
+ // where a staged retry ladder recovers if a rev2-only server rejects the RETURN
27
+ // option. The set mirrors the rest of the Appendix E list in full, including
28
+ // entries no call site consults yet, so any future capability check gets the
29
+ // rev2 folding for free.
26
30
  const IMAP4REV2_FOLDED_CAPABILITIES = new Set([
27
31
  'ENABLE',
28
32
  'ESEARCH',
@@ -308,7 +312,17 @@ const tools = {
308
312
  return false;
309
313
  }
310
314
 
311
- return (await compiler(response)).toString();
315
+ try {
316
+ return (await compiler(response)).toString();
317
+ } catch {
318
+ // The wire encoder refuses values that cannot be expressed as a valid IMAP
319
+ // string, which is what keeps user-supplied data from breaking out of a
320
+ // command. A server response is not held to that: the parser deliberately
321
+ // tolerates stray bytes inside an OK/NO/BAD atom, and those bytes then have
322
+ // no valid re-encoding. This text is diagnostic, so fall back to the logging
323
+ // encoder rather than replacing the server's error with an encoding failure.
324
+ return (await compiler(response, { isLogging: true })).toString();
325
+ }
312
326
  },
313
327
 
314
328
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.6.3",
3
+ "version": "1.6.5",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -606,6 +606,97 @@ module.exports['Commands: search drops * from ESEARCH UID results'] = async test
606
606
  test.done();
607
607
  };
608
608
 
609
+ module.exports['Commands: search discards invalid single values in an ESEARCH ALL set'] = async test => {
610
+ const connection = createMockConnection({
611
+ state: 3,
612
+ capabilities: new Map([['IMAP4rev2', true]]),
613
+ mailbox: { path: 'INBOX', exists: 5 },
614
+ exec: async (cmd, attrs, opts) => {
615
+ // '0' is not a valid nz-number and 'foo' is garbage - both single
616
+ // values must be dropped while the valid one survives
617
+ await opts.untagged.ESEARCH({
618
+ attributes: [
619
+ { type: 'ATOM', value: 'ALL' },
620
+ { type: 'ATOM', value: '0,foo,4' }
621
+ ]
622
+ });
623
+ return { next: () => {} };
624
+ }
625
+ });
626
+
627
+ const result = await searchCommand(connection, true, {});
628
+ test.deepEqual(result, [4]);
629
+ test.done();
630
+ };
631
+
632
+ module.exports['Commands: search truncates single-value ESEARCH ALL entries at the mailbox size'] = async test => {
633
+ const connection = createMockConnection({
634
+ state: 3,
635
+ capabilities: new Map([['IMAP4rev2', true]]),
636
+ mailbox: { path: 'INBOX', exists: 2 },
637
+ exec: async (cmd, attrs, opts) => {
638
+ // More single values than the mailbox holds - the walk must stop at
639
+ // the EXISTS budget instead of collecting the excess
640
+ await opts.untagged.ESEARCH({
641
+ attributes: [
642
+ { type: 'ATOM', value: 'ALL' },
643
+ { type: 'ATOM', value: '1,2,3,4' }
644
+ ]
645
+ });
646
+ return { next: () => {} };
647
+ }
648
+ });
649
+
650
+ const result = await searchCommand(connection, true, {});
651
+ test.deepEqual(result, [1, 2]);
652
+ test.done();
653
+ };
654
+
655
+ module.exports['Commands: search ignores an ESEARCH reply without attributes on the plain path'] = async test => {
656
+ const connection = createMockConnection({
657
+ state: 3,
658
+ capabilities: new Map([['IMAP4rev2', true]]),
659
+ exec: async (cmd, attrs, opts) => {
660
+ // A degenerate untagged ESEARCH with no attributes must not crash
661
+ // the collector or contribute results
662
+ await opts.untagged.ESEARCH({ attributes: null });
663
+ await opts.untagged.ESEARCH({
664
+ attributes: [
665
+ { type: 'ATOM', value: 'ALL' },
666
+ { type: 'ATOM', value: '2' }
667
+ ]
668
+ });
669
+ return { next: () => {} };
670
+ }
671
+ });
672
+
673
+ const result = await searchCommand(connection, true, {});
674
+ test.deepEqual(result, [2]);
675
+ test.done();
676
+ };
677
+
678
+ module.exports['Commands: search treats an ESEARCH ALL set without a mailbox size as empty'] = async test => {
679
+ const connection = createMockConnection({
680
+ state: 3,
681
+ capabilities: new Map([['IMAP4rev2', true]]),
682
+ // No exists value at all - the budget is zero, nothing may be collected
683
+ mailbox: { path: 'INBOX' },
684
+ exec: async (cmd, attrs, opts) => {
685
+ await opts.untagged.ESEARCH({
686
+ attributes: [
687
+ { type: 'ATOM', value: 'ALL' },
688
+ { type: 'ATOM', value: '1:3' }
689
+ ]
690
+ });
691
+ return { next: () => {} };
692
+ }
693
+ });
694
+
695
+ const result = await searchCommand(connection, true, {});
696
+ test.deepEqual(result, []);
697
+ test.done();
698
+ };
699
+
609
700
  module.exports['Commands: search with UID option'] = async test => {
610
701
  let execCmd = null;
611
702
  const connection = createMockConnection({
@@ -2819,6 +2910,40 @@ module.exports['Commands: compress handles error'] = async test => {
2819
2910
  test.done();
2820
2911
  };
2821
2912
 
2913
+ module.exports['Commands: compress fails the connection on trailing data'] = async test => {
2914
+ // Per RFC 4978 the server switches to DEFLATE at its tagged OK, so data already
2915
+ // buffered behind the OK was consumed as cleartext and the deflate stream is
2916
+ // truncated. Declining the upgrade is not a protocol option at that point - the
2917
+ // session is unrecoverable in both directions and must fail closed.
2918
+ let closeAfterCalled = false;
2919
+ let nextCalled = false;
2920
+ const connection = createMockConnection({
2921
+ capabilities: new Map([['COMPRESS=DEFLATE', true]]),
2922
+ closeAfter: () => {
2923
+ closeAfterCalled = true;
2924
+ },
2925
+ exec: async () => ({
2926
+ hasTrailingData: true,
2927
+ next: () => {
2928
+ nextCalled = true;
2929
+ test.ok(closeAfterCalled, 'teardown must be scheduled before parser backpressure is released');
2930
+ }
2931
+ })
2932
+ });
2933
+
2934
+ let err = null;
2935
+ try {
2936
+ await compressCommand(connection);
2937
+ } catch (e) {
2938
+ err = e;
2939
+ }
2940
+ test.ok(err, 'compress must throw');
2941
+ test.equal(err && err.code, 'COMPRESS_TRAILING_DATA');
2942
+ test.ok(closeAfterCalled, 'the connection must be closed');
2943
+ test.ok(nextCalled, 'parser backpressure must still be released');
2944
+ test.done();
2945
+ };
2946
+
2822
2947
  // ============================================
2823
2948
  // STARTTLS Command Tests
2824
2949
  // ============================================
@@ -3804,6 +3929,52 @@ module.exports['Commands: list runs separate INBOX query when using namespace']
3804
3929
  test.done();
3805
3930
  };
3806
3931
 
3932
+ module.exports['Commands: list INBOX fixup propagates non-reducible failures'] = async test => {
3933
+ // Both sides of the fixup's retry guard: a NO on the extended call is an operational
3934
+ // failure (not a RETURN options rejection), and a BAD on a call that already ran plain
3935
+ // has no options left to reduce - neither may trigger the plain retry
3936
+ for (let { capabilities, status } of [
3937
+ {
3938
+ capabilities: [
3939
+ ['IMAP4rev1', true],
3940
+ ['LIST-EXTENDED', true]
3941
+ ],
3942
+ status: 'NO'
3943
+ },
3944
+ { capabilities: [['IMAP4rev1', true]], status: 'BAD' }
3945
+ ]) {
3946
+ let listCalls = 0;
3947
+ let lsubCalls = 0;
3948
+ const connection = createMockConnection({
3949
+ state: 3,
3950
+ capabilities: new Map(capabilities),
3951
+ exec: async (cmd, attrs) => {
3952
+ if (cmd === 'LSUB') {
3953
+ lsubCalls++;
3954
+ }
3955
+ if (cmd === 'LIST') {
3956
+ listCalls++;
3957
+ if (attrs[1] === 'INBOX') {
3958
+ throw commandError('Command failed', status);
3959
+ }
3960
+ }
3961
+ return { next: () => {} };
3962
+ }
3963
+ });
3964
+
3965
+ try {
3966
+ await listCommand(connection, 'Mail/', '*');
3967
+ test.ok(false, 'Should have thrown');
3968
+ } catch (err) {
3969
+ test.equal(err.responseStatus, status);
3970
+ }
3971
+ // Main listing and the failed INBOX call - no plain retry, no LSUB merge
3972
+ test.equal(listCalls, 2);
3973
+ test.equal(lsubCalls, 0);
3974
+ }
3975
+ test.done();
3976
+ };
3977
+
3807
3978
  module.exports['Commands: list handles LSUB merging'] = async test => {
3808
3979
  const connection = createMockConnection({
3809
3980
  state: 3,
@@ -4760,18 +4931,28 @@ module.exports['Commands: list discards partial results from a rejected INBOX fi
4760
4931
  state: 3,
4761
4932
  capabilities: new Map([
4762
4933
  ['IMAP4rev1', true],
4763
- ['LIST-EXTENDED', true]
4934
+ ['LIST-EXTENDED', true],
4935
+ ['SPECIAL-USE', true]
4764
4936
  ]),
4765
4937
  exec: async (cmd, attrs, opts) => {
4766
4938
  if (cmd === 'LIST') {
4767
4939
  listAttempts.push(JSON.stringify(attrs));
4768
4940
  if (listAttempts.length === 1) {
4769
4941
  await opts.untagged.LIST({
4770
- attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '.' }, { value: 'Prefix.Folder1' }]
4942
+ attributes: [
4943
+ [{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }, { value: '\\Sent' }],
4944
+ { value: '.' },
4945
+ { value: 'Prefix.Folder1' }
4946
+ ]
4771
4947
  });
4772
4948
  } else if (listAttempts.length === 2) {
4773
- // The fixup attempt streams an untagged INBOX line and THEN gets
4774
- // the tagged BAD - the partial line must not survive the retry
4949
+ // The fixup attempt streams untagged lines and THEN gets the tagged
4950
+ // BAD - the partial lines must not survive the retry. "Aliased" also
4951
+ // claims \Sent and sorts ahead of the main run's Prefix.Folder1, so
4952
+ // it would steal the special-use slot if it were not rolled back
4953
+ await opts.untagged.LIST({
4954
+ attributes: [[{ value: '\\Sent' }], { value: '.' }, { value: 'Aliased' }]
4955
+ });
4775
4956
  await opts.untagged.LIST({
4776
4957
  attributes: [[{ value: '\\HasNoChildren' }], { value: '.' }, { value: 'INBOX' }]
4777
4958
  });
@@ -4790,7 +4971,9 @@ module.exports['Commands: list discards partial results from a rejected INBOX fi
4790
4971
  test.equal(listAttempts.length, 3);
4791
4972
  // Exactly one INBOX entry - the rejected attempt's partial line was discarded
4792
4973
  test.equal(result.filter(e => e.path === 'INBOX').length, 1);
4793
- test.ok(result.find(e => e.path === 'Prefix.Folder1'));
4974
+ test.ok(!result.some(e => e.path === 'Aliased'), 'the rejected attempt entry is gone');
4975
+ // The main run's special-use match survived the rollback of the rejected attempt
4976
+ test.equal(result.find(e => e.path === 'Prefix.Folder1').specialUse, '\\Sent');
4794
4977
  test.done();
4795
4978
  };
4796
4979
 
@@ -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
+ };