imapflow 1.4.9 → 1.6.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.
Files changed (48) hide show
  1. package/.github/workflows/test.yml +20 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/CLAUDE.md +3 -5
  5. package/lib/commands/fetch.js +18 -14
  6. package/lib/commands/idle.js +197 -104
  7. package/lib/commands/list.js +19 -8
  8. package/lib/commands/quota.js +3 -0
  9. package/lib/commands/select.js +5 -0
  10. package/lib/commands/status.js +10 -1
  11. package/lib/connection-deadline.js +98 -0
  12. package/lib/handler/imap-compiler.js +20 -14
  13. package/lib/handler/imap-stream.js +141 -50
  14. package/lib/handler/limits.js +43 -0
  15. package/lib/handler/token-parser.js +38 -1
  16. package/lib/imap-flow.d.ts +47 -5
  17. package/lib/imap-flow.js +594 -283
  18. package/lib/proxy-connection.js +393 -98
  19. package/lib/special-use.js +660 -51
  20. package/lib/tools.js +52 -3
  21. package/package.json +2 -2
  22. package/test/commands-branches-test.js +17 -1
  23. package/test/commands-integration-test.js +353 -2
  24. package/test/connection-edge-cases-test.js +4 -40
  25. package/test/fixtures/fake-timers.js +115 -0
  26. package/test/handler-branches-test.js +4 -28
  27. package/test/idle-polling-test.js +349 -0
  28. package/test/imap-compiler-test.js +85 -0
  29. package/test/imap-flow-compress-test.js +12 -0
  30. package/test/imap-flow-coverage-test.js +3 -3
  31. package/test/imap-flow-fetch-download-test.js +56 -0
  32. package/test/imap-flow-internals-test.js +23 -0
  33. package/test/imap-flow-proxy-paths-test.js +151 -0
  34. package/test/imap-flow-secure-test.js +182 -9
  35. package/test/imap-flow-server-test.js +229 -0
  36. package/test/imap-parser-test.js +112 -1
  37. package/test/imap-stream-test.js +46 -0
  38. package/test/integration/README.md +17 -5
  39. package/test/integration/rev2-live-test.js +125 -0
  40. package/test/integration/run-rev2-tests.sh +14 -0
  41. package/test/parser-limits-test.js +274 -0
  42. package/test/proxy-connection-test.js +553 -442
  43. package/test/reliability-improvements-test.js +87 -0
  44. package/test/search-compiler-test.js +17 -0
  45. package/test/special-use-test.js +337 -0
  46. package/test/tag-correlation-test.js +333 -0
  47. package/test/timer-policy-test.js +214 -0
  48. package/test/tools-test.js +42 -4
package/lib/imap-flow.js CHANGED
@@ -24,6 +24,7 @@ const FlowedDecoder = require('@zone-eu/mailsplit/lib/flowed-decoder');
24
24
  const { PassThrough } = require('stream');
25
25
 
26
26
  const { proxyConnection, detachEarlyErrorHandler } = require('./proxy-connection');
27
+ const { ConnectionDeadline } = require('./connection-deadline');
27
28
 
28
29
  const {
29
30
  comparePaths,
@@ -36,14 +37,14 @@ const {
36
37
  expandRange,
37
38
  AuthenticationFailure,
38
39
  getColorFlags,
39
- hasCapability
40
+ hasCapability,
41
+ unrefTimer
40
42
  } = require('./tools');
41
43
 
42
44
  const imapCommands = require('./imap-commands.js');
43
45
 
44
46
  const noop = () => {};
45
47
 
46
- const CONNECT_TIMEOUT = 90 * 1000;
47
48
  const GREETING_TIMEOUT = 16 * 1000;
48
49
  const UPGRADE_TIMEOUT = 10 * 1000;
49
50
 
@@ -205,7 +206,21 @@ class ImapFlow extends EventEmitter {
205
206
  * If `true`, disconnects after successful authentication without performing other actions.
206
207
  *
207
208
  * @property {String} [proxy]
208
- * Proxy URL. Supports HTTP CONNECT (`http://`, `https://`) and SOCKS (`socks://`, `socks4://`, `socks5://`).
209
+ * Proxy URL. Supports HTTP CONNECT (`http://`, `https://`) and SOCKS (`socks://`, `socks4://`, `socks4a://`, `socks5://`).
210
+ * IPv6 proxy endpoints use the URL form, e.g. `socks5://[2001:db8::1]:1080`.
211
+ *
212
+ * DNS behaviour depends on the proxy protocol:
213
+ * - `http`/`https`: the destination hostname is sent to the proxy unresolved.
214
+ * - `socks4`: destination hostnames are resolved locally to IPv4, because SOCKS4 carries
215
+ * only IPv4 destination addresses and a hostname would silently become a SOCKS4a
216
+ * request. IPv6 destinations are rejected.
217
+ * - `socks4a`: destination hostnames are sent to the proxy for remote DNS. IPv6
218
+ * destinations are rejected.
219
+ * - `socks`/`socks5`: destination hostnames are sent to the proxy for remote DNS, and
220
+ * IPv4/IPv6 literals are passed through unchanged.
221
+ *
222
+ * The proxy endpoint itself is never resolved by ImapFlow - a hostname endpoint is handed
223
+ * to Node as-is, keeping its normal lookup and connection behaviour.
209
224
  *
210
225
  * @property {Boolean} [qresync=false]
211
226
  * If `true`, enables QRESYNC support so that EXPUNGE notifications include `uid` instead of `seq`.
@@ -228,7 +243,9 @@ class ImapFlow extends EventEmitter {
228
243
  * without losing the other auto-enabled extensions.
229
244
  *
230
245
  * @property {Number} [connectionTimeout=90000]
231
- * Maximum time (in milliseconds) to wait for the connection to establish. Defaults to 90 seconds.
246
+ * Maximum time (in milliseconds) to wait for a usable transport. Covers DNS resolution,
247
+ * proxy negotiation and the TCP/TLS handshake as a single budget, so an expiry in any of
248
+ * those phases rejects with error code `CONNECT_TIMEOUT`. Defaults to 90 seconds.
232
249
  *
233
250
  * @property {Number} [greetingTimeout=16000]
234
251
  * Maximum time (in milliseconds) to wait for the server greeting after a connection is established. Defaults to 16 seconds.
@@ -294,6 +311,11 @@ class ImapFlow extends EventEmitter {
294
311
  this.secureConnection = true;
295
312
  }
296
313
 
314
+ // Normalized once so direct TLS, cleartext, proxied and STARTTLS-upgraded transports
315
+ // cannot end up with different inactivity watchdogs. As documented, 0 (and any other
316
+ // falsy or invalid value) means "use the default", not "disable".
317
+ this.socketTimeout = Number(this.options.socketTimeout) || SOCKET_TIMEOUT;
318
+
297
319
  this.logRaw = this.options.logRaw;
298
320
  this.streamer = new ImapStream({
299
321
  logger: this.log,
@@ -330,6 +352,14 @@ class ImapFlow extends EventEmitter {
330
352
  this.requestQueue = [];
331
353
  this.currentRequest = false;
332
354
 
355
+ // Count of tagged responses whose tag was never issued by this connection. Tolerated
356
+ // (non-conforming servers do this) but tracked, so the compatibility decision in
357
+ // countUnknownTag() can be revisited with field data instead of guesses. Warnings are
358
+ // emitted at the milestones below (1, 2, 4, 8, ...) so the count stays exact without
359
+ // turning a spraying server into a log flood.
360
+ this._unknownTagCount = 0;
361
+ this._nextUnknownTagWarn = 1;
362
+
333
363
  this.writeBytesCounter = 0;
334
364
 
335
365
  this.commandParts = [];
@@ -436,19 +466,21 @@ class ImapFlow extends EventEmitter {
436
466
  }
437
467
  err._connId = err._connId || this.id;
438
468
 
439
- // During a STARTTLS handshake the upgrade promise owns the single error path
440
- // (tlsSocketErrorHandler -> reject). Route the error there so a streamer-originated
441
- // failure is surfaced with its real code (instead of a generic ClosedAfterConnect*)
442
- // and cannot hang a verifyOnly connect() waiting on a 'close' that never rejects.
443
- // Fall back to closing if the upgrade has no pending rejector.
469
+ // During a STARTTLS handshake the upgrade owns the single error path (its settle()
470
+ // helper). Route the error there so a streamer-originated failure is surfaced with its
471
+ // real code (instead of a generic ClosedAfterConnect*) and cannot hang a verifyOnly
472
+ // connect() waiting on a 'close' that never rejects. Fall back to closing if the upgrade
473
+ // has no pending rejector.
444
474
  if (this.upgrading) {
445
- this.upgrading = false;
446
- this.closeAfter();
447
- if (typeof this._upgradeReject === 'function') {
448
- let reject = this._upgradeReject;
449
- this._upgradeReject = null;
475
+ let reject = this._upgradeReject;
476
+ this._upgradeReject = null;
477
+ if (typeof reject === 'function') {
478
+ // settle() clears the upgrade timer and flags, and closes the connection
450
479
  reject(err);
480
+ return;
451
481
  }
482
+ this.upgrading = false;
483
+ this.closeAfter();
452
484
  return;
453
485
  }
454
486
 
@@ -603,6 +635,13 @@ class ImapFlow extends EventEmitter {
603
635
  // send each remaining part from this.commandParts.
604
636
  this.write(this.commandParts.shift());
605
637
 
638
+ // The command is on the wire now. Tagged-response correlation requires this, so a server
639
+ // that guesses the next (sequential) tag cannot settle a command during the window between
640
+ // it becoming current and actually being written.
641
+ if (this.currentRequest && this.currentRequest.tag === data.tag) {
642
+ this.currentRequest.sent = true;
643
+ }
644
+
606
645
  if (typeof options.onSend === 'function') {
607
646
  options.onSend();
608
647
  }
@@ -694,228 +733,369 @@ class ImapFlow extends EventEmitter {
694
733
  }
695
734
  }
696
735
 
736
+ // Releases a readable stream item exactly once. The item's `next` callback is the parser's
737
+ // backpressure token: until it is called, ImapStream stops feeding the connection. Every
738
+ // path out of response handling - success, handled error, or unexpected throw - has to go
739
+ // through here, otherwise the parser stalls permanently.
740
+ releaseStreamData(data) {
741
+ if (!data || data.released) {
742
+ return;
743
+ }
744
+ data.released = true;
745
+ if (typeof data.next === 'function') {
746
+ data.next();
747
+ }
748
+ }
749
+
750
+ // Records a tagged response whose tag was never issued by this connection. ImapFlow talks
751
+ // to a wide range of non-conforming servers, so this is tolerated rather than terminal, but
752
+ // it must not pass silently. Warnings are emitted for the first occurrence and then at
753
+ // powers of two so a server spraying stray tagged lines cannot flood the log, while the
754
+ // counter itself stays exact and is reported when the connection closes.
755
+ countUnknownTag(tag) {
756
+ if (this.isClosed) {
757
+ // teardown crossover, not a server compatibility signal
758
+ return;
759
+ }
760
+
761
+ this._unknownTagCount++;
762
+ if (this._unknownTagCount === this._nextUnknownTagWarn) {
763
+ this._nextUnknownTagWarn *= 2;
764
+ this.log.warn({
765
+ msg: 'Tagged response for an unknown tag',
766
+ tag,
767
+ unknownTagCount: this._unknownTagCount,
768
+ cid: this.id
769
+ });
770
+ }
771
+ }
772
+
773
+ // Terminally fails the connection on a protocol violation: stop parsing, then report. Both
774
+ // steps are explicit here rather than destroying the parser *with* the error and relying on
775
+ // its error listener to report, so the reporting path does not depend on teardown ordering or
776
+ // on the streamer error handler's suppression list.
777
+ failProtocol(err) {
778
+ if (this.streamer && !this.streamer.destroyed) {
779
+ // Destroyed without an error: nothing after a protocol violation may reach
780
+ // application state, and emitError() below owns reporting.
781
+ this.streamer.destroy();
782
+ }
783
+ this.emitError(err);
784
+ }
785
+
786
+ // Rejects the in-flight request, if any, exactly once. Used when response handling fails in
787
+ // a way that leaves the command's outcome unknown.
788
+ rejectCurrentRequest(err) {
789
+ if (!this.currentRequest) {
790
+ return;
791
+ }
792
+ let tag = this.currentRequest.tag;
793
+ this.currentRequest = false;
794
+ let request = this.requestTagMap.get(tag);
795
+ if (request) {
796
+ this.requestTagMap.delete(tag);
797
+ request.reject(err);
798
+ }
799
+ }
800
+
697
801
  async reader() {
698
802
  let data;
699
803
  let processedCount = 0;
700
804
  while ((data = this.streamer.read()) !== null) {
701
- let parsed;
805
+ let keepReading;
702
806
 
703
807
  try {
704
- parsed = await parser(data.payload, { literals: data.literals });
705
- if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
706
- let payload = { response: parsed.command };
707
-
708
- if (
709
- parsed.attributes &&
710
- parsed.attributes[0] &&
711
- parsed.attributes[0].section &&
712
- parsed.attributes[0].section[0] &&
713
- parsed.attributes[0].section[0].type === 'ATOM'
714
- ) {
715
- payload.code = parsed.attributes[0].section[0].value;
716
- }
717
- this.emit('response', payload);
718
- }
808
+ keepReading = await this.handleResponse(data);
719
809
  } catch (err) {
720
- // can not make sense of this
721
- this.log.error({ src: 's', msg: data.payload.toString(), err, cid: this.id });
722
- data.next();
723
- continue;
810
+ // Response handling past the parse step (log compilation, response shape
811
+ // assumptions, an untagged handler bug) must never throw out of this loop: the
812
+ // parser would keep waiting on its backpressure callback forever, which is a
813
+ // silent permanent hang. Fail closed instead.
814
+ keepReading = false;
815
+ let error = new Error('Failed to process server response');
816
+ error.code = 'ResponseProcessingFailed';
817
+ error._err = err;
818
+ this.log.error({ msg: 'Failed to process server response', err, cid: this.id });
819
+ this.rejectCurrentRequest(error);
820
+ this.failProtocol(error);
821
+ } finally {
822
+ this.releaseStreamData(data);
724
823
  }
725
824
 
726
- let logCompiled = await compiler(parsed, {
727
- isLogging: true
728
- });
825
+ if (!keepReading) {
826
+ return;
827
+ }
729
828
 
730
- if (/^\d+$/.test(parsed.command) && parsed.attributes && parsed.attributes[0] && parsed.attributes[0].value === 'FETCH') {
731
- // too many FETCH responses, might want to filter these out
732
- this.log.trace({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
733
- } else {
734
- this.log.debug({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
829
+ // Yield to event loop every 10 processed messages to prevent CPU blocking
830
+ processedCount++;
831
+ if (processedCount % 10 === 0) {
832
+ await new Promise(resolve => setImmediate(resolve));
735
833
  }
834
+ }
835
+ }
836
+
837
+ /**
838
+ * Handles a single parsed server response: telemetry, continuation requests, response-code
839
+ * section handlers, untagged handlers and tagged command completion.
840
+ *
841
+ * @param {Object} data - Readable item from the parser stream.
842
+ * @returns {Promise<Boolean>} `true` to keep reading, `false` to stop (connection is failing).
843
+ */
844
+ async handleResponse(data) {
845
+ let parsed;
846
+
847
+ try {
848
+ 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
+ } catch (err) {
864
+ // can not make sense of this
865
+ this.log.error({ src: 's', msg: data.payload.toString(), err, cid: this.id });
866
+ return true;
867
+ }
736
868
 
737
- // IMAP "+" (continuation request) handling. The server sends "+" in two cases:
738
- // 1. During IDLE or AUTHENTICATE, where a custom handler (onPlusTag) processes it
739
- // 2. During literal data transfer, where we send the next queued literal chunk
740
- if (parsed.tag === '+' && this.currentRequest && this.currentRequest.options && typeof this.currentRequest.options.onPlusTag === 'function') {
869
+ let logCompiled = await compiler(parsed, {
870
+ isLogging: true
871
+ });
872
+
873
+ if (/^\d+$/.test(parsed.command) && parsed.attributes && parsed.attributes[0] && parsed.attributes[0].value === 'FETCH') {
874
+ // too many FETCH responses, might want to filter these out
875
+ this.log.trace({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
876
+ } else {
877
+ this.log.debug({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
878
+ }
879
+
880
+ // IMAP "+" (continuation request) handling. The server sends "+" in two cases:
881
+ // 1. During IDLE or AUTHENTICATE, where a custom handler (onPlusTag) processes it
882
+ // 2. During literal data transfer, where we send the next queued literal chunk
883
+ if (parsed.tag === '+' && this.currentRequest && this.currentRequest.options && typeof this.currentRequest.options.onPlusTag === 'function') {
884
+ try {
885
+ await this.currentRequest.options.onPlusTag(parsed);
886
+ } catch (err) {
887
+ this.log.warn({ err, cid: this.id });
888
+ }
889
+ return true;
890
+ }
891
+
892
+ // Server acknowledged our literal size with "+", send the actual literal data
893
+ if (parsed.tag === '+' && this.commandParts.length) {
894
+ let content = this.commandParts.shift();
895
+ // A write() failure here (e.g. socket closed mid-command) must not fail the whole
896
+ // connection; the command's own tagged response or the close path reports it.
897
+ try {
898
+ this.write(content);
899
+ this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
900
+ } catch (err) {
901
+ this.log.warn({ err, cid: this.id });
902
+ }
903
+ return true;
904
+ }
905
+
906
+ let section = parsed.attributes && parsed.attributes.length && parsed.attributes[0] && !parsed.attributes[0].value && parsed.attributes[0].section;
907
+ if (section && section.length && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
908
+ let sectionHandler = this.getSectionHandler(section[0].value.toUpperCase().trim());
909
+ if (sectionHandler) {
741
910
  try {
742
- await this.currentRequest.options.onPlusTag(parsed);
911
+ await sectionHandler(section.slice(1));
743
912
  } catch (err) {
744
913
  this.log.warn({ err, cid: this.id });
745
914
  }
746
- data.next();
747
- continue;
748
915
  }
916
+ }
749
917
 
750
- // Server acknowledged our literal size with "+", send the actual literal data
751
- if (parsed.tag === '+' && this.commandParts.length) {
752
- let content = this.commandParts.shift();
753
- // A write() failure here (e.g. socket closed mid-command) must not propagate
754
- // out of the loop and skip data.next(), which would stall the parser stream.
918
+ if (parsed.tag === '*' && parsed.command) {
919
+ let untaggedHandler = this.getUntaggedHandler(parsed.command, parsed.attributes);
920
+ if (untaggedHandler) {
755
921
  try {
756
- this.write(content);
757
- this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
922
+ await untaggedHandler(parsed);
758
923
  } catch (err) {
759
924
  this.log.warn({ err, cid: this.id });
925
+ return true;
760
926
  }
761
- data.next();
762
- continue;
763
927
  }
928
+ }
764
929
 
765
- let section = parsed.attributes && parsed.attributes.length && parsed.attributes[0] && !parsed.attributes[0].value && parsed.attributes[0].section;
766
- if (section && section.length && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
767
- let sectionHandler = this.getSectionHandler(section[0].value.toUpperCase().trim());
768
- if (sectionHandler) {
769
- try {
770
- await sectionHandler(section.slice(1));
771
- } catch (err) {
772
- this.log.warn({ err, cid: this.id });
773
- }
774
- }
775
- }
930
+ // Tagged response correlation. A tagged response may only complete the command that was
931
+ // actually written to the socket (invariant 2), so the three cases below are kept apart:
932
+ // the active command completes, a command that has not been written yet is proof of
933
+ // desynchronization (queued behind another command, or current but not yet on the wire),
934
+ // and an entirely unknown tag is recorded but tolerated.
935
+ if (parsed.tag && !['*', '+'].includes(parsed.tag)) {
936
+ if (this.currentRequest && this.currentRequest.tag === parsed.tag && this.currentRequest.sent) {
937
+ let request = this.requestTagMap.get(parsed.tag);
938
+ this.requestTagMap.delete(parsed.tag);
939
+ this.currentRequest = false;
776
940
 
777
- if (parsed.tag === '*' && parsed.command) {
778
- let untaggedHandler = this.getUntaggedHandler(parsed.command, parsed.attributes);
779
- if (untaggedHandler) {
780
- try {
781
- await untaggedHandler(parsed);
782
- } catch (err) {
783
- this.log.warn({ err, cid: this.id });
784
- data.next();
785
- continue;
786
- }
941
+ if (request) {
942
+ await this.settleRequest(request, parsed, !!data.trailingAfterLine);
787
943
  }
788
- }
789
944
 
790
- if (this.requestTagMap.has(parsed.tag)) {
945
+ // Send the next queued command only after the completed command's handler has
946
+ // applied its own state (e.g. select.js publishing the new mailbox), so the next
947
+ // command cannot reach the wire against half-updated state. A failure here must
948
+ // not propagate, or the whole connection would be failed over a send error that
949
+ // the command's own promise already reports.
950
+ // Note: on a rejected command the handler's catch block runs on its own microtask
951
+ // chain, so only the success path is fully ordered.
952
+ try {
953
+ await this.trySend();
954
+ } catch (err) {
955
+ this.log.warn({ err, cid: this.id });
956
+ }
957
+ } else if (this.requestTagMap.has(parsed.tag)) {
958
+ // The server answered a command that has not been written to the socket yet.
959
+ // Continuing would report unsent mutations as successful and leave every later
960
+ // response ambiguous, so reject this request and fail the connection closed.
791
961
  let request = this.requestTagMap.get(parsed.tag);
792
962
  this.requestTagMap.delete(parsed.tag);
793
963
 
794
- if (this.currentRequest && this.currentRequest.tag === parsed.tag) {
795
- // send next pending command. A failure here must not propagate out of the
796
- // loop and skip data.next() below, which would stall the parser stream.
797
- this.currentRequest = false;
798
- try {
799
- await this.trySend();
800
- } catch (err) {
801
- this.log.warn({ err, cid: this.id });
802
- }
803
- }
804
-
805
- switch (parsed.command.toUpperCase()) {
806
- case 'OK':
807
- case 'BYE':
808
- // hasTrailingData is forwarded so STARTTLS can detect a plaintext
809
- // injection (data buffered after the tagged OK, before the handshake).
810
- await new Promise(resolve => request.resolve({ response: parsed, next: resolve, hasTrailingData: !!data.trailingAfterLine }));
811
- break;
964
+ let err = new Error('Server sent a tagged response for a command that was not in flight');
965
+ err.code = 'UnexpectedTag';
966
+ err.details = {
967
+ received: parsed.tag,
968
+ expected: this.currentRequest ? this.currentRequest.tag : null
969
+ };
812
970
 
813
- case 'NO':
814
- case 'BAD': {
815
- let txt =
816
- parsed.attributes &&
817
- parsed.attributes
818
- .filter(val => val.type === 'TEXT')
819
- .map(val => val.value.trim())
820
- .join(' ');
971
+ this.log.error({ msg: 'Protocol desynchronization', err, cid: this.id });
972
+ request.reject(err);
973
+ this.failProtocol(err);
974
+ return false;
975
+ } else {
976
+ this.countUnknownTag(parsed.tag);
977
+ }
978
+ }
821
979
 
822
- let err = new Error('Command failed');
823
- err.response = parsed;
824
- err.responseStatus = parsed.command.toUpperCase();
980
+ return true;
981
+ }
825
982
 
826
- try {
827
- err.executedCommand =
828
- parsed.tag +
829
- (
830
- await compiler(request, {
831
- isLogging: true
832
- })
833
- ).toString();
834
- } catch {
835
- // ignore
836
- }
983
+ /**
984
+ * Settles a request with its tagged completion response.
985
+ *
986
+ * On success the returned promise stays pending until the command handler calls `next()` on
987
+ * the response, which is what orders state application before the next queued command is
988
+ * dispatched. A command handler must therefore always release its own response before
989
+ * awaiting another command on the same connection.
990
+ *
991
+ * @param {Object} request - Pending request entry (resolve/reject and the compiled command).
992
+ * @param {Object} parsed - Parsed tagged response.
993
+ * @param {Boolean} hasTrailingData - Whether more input was already buffered after this line.
994
+ * @returns {Promise<void>}
995
+ */
996
+ async settleRequest(request, parsed, hasTrailingData) {
997
+ switch ((parsed.command || '').toUpperCase()) {
998
+ case 'OK':
999
+ case 'BYE':
1000
+ // hasTrailingData is forwarded so STARTTLS can detect a plaintext
1001
+ // injection (data buffered after the tagged OK, before the handshake).
1002
+ await new Promise(resolve => request.resolve({ response: parsed, next: resolve, hasTrailingData }));
1003
+ break;
837
1004
 
838
- if (txt) {
839
- err.responseText = txt;
1005
+ case 'NO':
1006
+ case 'BAD': {
1007
+ let txt =
1008
+ parsed.attributes &&
1009
+ parsed.attributes
1010
+ .filter(val => val.type === 'TEXT')
1011
+ .map(val => val.value.trim())
1012
+ .join(' ');
840
1013
 
841
- if (err.responseStatus === 'NO' && txt.includes('Some of the requested messages no longer exist')) {
842
- // Treat as successful response
843
- this.log.warn({ msg: 'Partial FETCH response', cid: this.id, err });
844
- await new Promise(resolve => request.resolve({ response: parsed, next: resolve }));
845
- break;
846
- }
1014
+ let err = new Error('Command failed');
1015
+ err.response = parsed;
1016
+ err.responseStatus = parsed.command.toUpperCase();
847
1017
 
848
- let throttleDelay = false;
1018
+ try {
1019
+ err.executedCommand =
1020
+ parsed.tag +
1021
+ (
1022
+ await compiler(request, {
1023
+ isLogging: true
1024
+ })
1025
+ ).toString();
1026
+ } catch {
1027
+ // ignore
1028
+ }
849
1029
 
850
- // MS365 throttling detection: Office 365 returns BAD with a human-readable
851
- // backoff time when rate limits are hit. Parse the delay from the response text.
852
- // Example: "tag BAD Request is throttled. Suggested Backoff Time: 92415 milliseconds"
853
- if (/Request is throttled/i.test(txt) && /Backoff Time/i.test(txt)) {
854
- let throttlingMatch = txt.match(/Backoff Time[:=\s]+(\d+)/i);
855
- if (throttlingMatch && throttlingMatch[1] && !isNaN(throttlingMatch[1])) {
856
- throttleDelay = Number(throttlingMatch[1]);
857
- }
858
- }
1030
+ if (txt) {
1031
+ err.responseText = txt;
859
1032
 
860
- // Wait and return a throttling error
861
- if (throttleDelay) {
862
- err.code = 'ETHROTTLE';
863
- err.throttleReset = throttleDelay;
1033
+ if (err.responseStatus === 'NO' && txt.includes('Some of the requested messages no longer exist')) {
1034
+ // Treat as successful response
1035
+ this.log.warn({ msg: 'Partial FETCH response', cid: this.id, err });
1036
+ await new Promise(resolve => request.resolve({ response: parsed, next: resolve }));
1037
+ break;
1038
+ }
864
1039
 
865
- let delayResponse = throttleDelay;
866
- if (delayResponse > 5 * 60 * 1000) {
867
- // Cap wait at 5 minutes to avoid hanging connections indefinitely.
868
- // The server-suggested delay can be very large.
869
- delayResponse = 5 * 60 * 1000;
870
- }
1040
+ let throttleDelay = false;
871
1041
 
872
- this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err });
873
-
874
- // Tracked, abortable wait. Storing the timer lets close() clear it so
875
- // the back-off never keeps the event loop alive, and storing the resolve
876
- // lets close() abort the wait promptly (aborted=true) instead of blocking
877
- // the reader for up to 5 minutes and rejecting long after the connection
878
- // is gone. Normal expiry resolves with aborted=false.
879
- let aborted = await new Promise(resolve => {
880
- this._throttleAbort = resolve;
881
- this._throttleTimer = setTimeout(() => resolve(false), delayResponse);
882
- if (typeof this._throttleTimer.unref === 'function') {
883
- this._throttleTimer.unref();
884
- }
885
- });
886
- this._throttleTimer = null;
887
- this._throttleAbort = null;
888
-
889
- if (aborted) {
890
- // Connection closed during back-off: reject promptly with a
891
- // connection error (carrying any server BYE reason) instead of
892
- // waiting out the throttle delay.
893
- request.reject(this.createNoConnectionError(this.byeReason));
894
- break;
895
- }
896
- }
1042
+ // MS365 throttling detection: Office 365 returns BAD with a human-readable
1043
+ // backoff time when rate limits are hit. Parse the delay from the response text.
1044
+ // Example: "tag BAD Request is throttled. Suggested Backoff Time: 92415 milliseconds"
1045
+ if (/Request is throttled/i.test(txt) && /Backoff Time/i.test(txt)) {
1046
+ let throttlingMatch = txt.match(/Backoff Time[:=\s]+(\d+)/i);
1047
+ if (throttlingMatch && throttlingMatch[1] && !isNaN(throttlingMatch[1])) {
1048
+ throttleDelay = Number(throttlingMatch[1]);
897
1049
  }
898
-
899
- request.reject(err);
900
- break;
901
1050
  }
902
1051
 
903
- default: {
904
- let err = new Error('Invalid server response');
905
- err.code = 'InvalidResponse';
906
- err.response = parsed;
907
- request.reject(err);
908
- break;
1052
+ // Wait and return a throttling error
1053
+ if (throttleDelay) {
1054
+ err.code = 'ETHROTTLE';
1055
+ err.throttleReset = throttleDelay;
1056
+
1057
+ let delayResponse = throttleDelay;
1058
+ if (delayResponse > 5 * 60 * 1000) {
1059
+ // Cap wait at 5 minutes to avoid hanging connections indefinitely.
1060
+ // The server-suggested delay can be very large.
1061
+ delayResponse = 5 * 60 * 1000;
1062
+ }
1063
+
1064
+ this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err });
1065
+
1066
+ // Tracked, abortable wait. Storing the timer lets close() clear it so
1067
+ // the back-off never keeps the event loop alive, and storing the resolve
1068
+ // lets close() abort the wait promptly (aborted=true) instead of blocking
1069
+ // the reader for up to 5 minutes and rejecting long after the connection
1070
+ // is gone. Normal expiry resolves with aborted=false.
1071
+ let aborted = await new Promise(resolve => {
1072
+ this._throttleAbort = resolve;
1073
+ this._throttleTimer = setTimeout(() => resolve(false), delayResponse);
1074
+ unrefTimer(this._throttleTimer);
1075
+ });
1076
+ this._throttleTimer = null;
1077
+ this._throttleAbort = null;
1078
+
1079
+ if (aborted) {
1080
+ // Connection closed during back-off: reject promptly with a
1081
+ // connection error (carrying any server BYE reason) instead of
1082
+ // waiting out the throttle delay.
1083
+ request.reject(this.createNoConnectionError(this.byeReason));
1084
+ break;
1085
+ }
909
1086
  }
910
1087
  }
911
- }
912
1088
 
913
- data.next();
1089
+ request.reject(err);
1090
+ break;
1091
+ }
914
1092
 
915
- // Yield to event loop every 10 processed messages to prevent CPU blocking
916
- processedCount++;
917
- if (processedCount % 10 === 0) {
918
- await new Promise(resolve => setImmediate(resolve));
1093
+ default: {
1094
+ let err = new Error('Invalid server response');
1095
+ err.code = 'InvalidResponse';
1096
+ err.response = parsed;
1097
+ request.reject(err);
1098
+ break;
919
1099
  }
920
1100
  }
921
1101
  }
@@ -939,6 +1119,29 @@ class ImapFlow extends EventEmitter {
939
1119
  this.streamer.on('readable', this.socketReadable);
940
1120
  }
941
1121
 
1122
+ /**
1123
+ * Applies the transport options every established application socket needs: TCP keepalive and
1124
+ * the inactivity watchdog. Called for direct TLS, cleartext, proxied and STARTTLS-upgraded
1125
+ * sockets, so the watchdog cannot silently differ between transports (a STARTTLS session used
1126
+ * to end up with no armed timer at all).
1127
+ *
1128
+ * @param {Object} socket - The socket that now carries the IMAP session.
1129
+ */
1130
+ configureSocket(socket) {
1131
+ /* c8 ignore next 3 */ // defensive: connect() only calls this with an established socket
1132
+ if (!socket) {
1133
+ return;
1134
+ }
1135
+
1136
+ if (typeof socket.setKeepAlive === 'function') {
1137
+ socket.setKeepAlive(true, 5 * 1000);
1138
+ }
1139
+
1140
+ if (typeof socket.setTimeout === 'function') {
1141
+ socket.setTimeout(this.socketTimeout);
1142
+ }
1143
+ }
1144
+
942
1145
  setSocketHandlers() {
943
1146
  // Clear any existing handlers first to prevent duplicates
944
1147
  this.clearSocketHandlers();
@@ -971,7 +1174,11 @@ class ImapFlow extends EventEmitter {
971
1174
  this.emitError(err);
972
1175
  return;
973
1176
  }
974
- // Attempt to recover IDLE connections
1177
+ // Attempt to recover IDLE connections. During true IDLE the NOOP cannot
1178
+ // reach the server until IDLE has been terminated: run() awaits preCheck(),
1179
+ // which sends DONE and only resolves once the server has completed the IDLE
1180
+ // command. Fallback polling has no such handshake - preCheck() there just
1181
+ // cancels the polling session.
975
1182
  this.run('NOOP')
976
1183
  .then(() => this.idle())
977
1184
  .catch(err => {
@@ -1150,9 +1357,10 @@ class ImapFlow extends EventEmitter {
1150
1357
  };
1151
1358
  /* c8 ignore stop */
1152
1359
 
1153
- Object.defineProperty(this.writeSocket, 'destroyed', {
1154
- get: () => !this.socket || this.socket.destroyed
1155
- });
1360
+ // The PassThrough reports its own `destroyed` state. It used to proxy the raw socket's
1361
+ // instead, which made close() skip destroying it and left the second raw-socket teardown
1362
+ // branch unreachable. write() checks the raw socket separately, so nothing depends on the
1363
+ // two states being conflated.
1156
1364
 
1157
1365
  // Manual pump loop: reads chunks from writeSocket, pushes them into
1158
1366
  // deflate, and flushes when the buffer is drained. This ensures each
@@ -1289,8 +1497,6 @@ class ImapFlow extends EventEmitter {
1289
1497
  throw failSTARTTLSInjection();
1290
1498
  }
1291
1499
  let upgraded = await new Promise((resolve, reject) => {
1292
- // Expose this rejector so emitError() can settle the upgrade with a streamer error.
1293
- this._upgradeReject = reject;
1294
1500
  let socketPlain = this.socket;
1295
1501
  let opts = Object.assign(
1296
1502
  {
@@ -1302,71 +1508,73 @@ class ImapFlow extends EventEmitter {
1302
1508
  );
1303
1509
  this.clearSocketHandlers();
1304
1510
 
1305
- // Store error handler for cleanup after successful upgrade
1306
- /* c8 ignore start */ // plain-socket error during the TLS handshake window is timing-dependent (errors surface via the streamer/emitError path in tests)
1307
- const socketPlainErrorHandler = err => {
1308
- clearTimeout(this.connectTimeout);
1309
- clearTimeout(this.upgradeTimeout);
1310
- if (!this.upgrading) {
1311
- // don't care anymore
1511
+ let settled = false;
1512
+
1513
+ // Single settlement path for the upgrade. Every terminal outcome - handshake
1514
+ // success, an error on the plain or the TLS socket, the upgrade timeout, an
1515
+ // explicit close(), or a streamer error routed here by emitError() - goes through
1516
+ // this helper exactly once. It owns clearing the upgrade timer, the exposed
1517
+ // rejector, the `upgrading` flag and the temporary handshake handlers, so a late
1518
+ // socket event cannot re-enter an already settled upgrade or leave state behind.
1519
+ const settle = (err, result) => {
1520
+ if (settled) {
1312
1521
  return;
1313
1522
  }
1314
- this.closeAfter();
1523
+ settled = true;
1524
+
1525
+ clearTimeout(this.upgradeTimeout);
1526
+ this.upgradeTimeout = null;
1315
1527
  this.upgrading = false;
1316
- err.tlsFailed = true;
1317
- reject(err);
1528
+ this._upgradeReject = null;
1529
+
1530
+ socketPlain.removeListener('error', settle);
1531
+ if (this.socket && this.socket !== socketPlain) {
1532
+ this.socket.removeListener('error', settle);
1533
+ }
1534
+
1535
+ if (err) {
1536
+ clearTimeout(this.connectTimeout);
1537
+ // Preserve the original error, marked as a TLS failure so callers can tell
1538
+ // an upgrade failure from an ordinary command failure.
1539
+ err.tlsFailed = true;
1540
+ this.closeAfter();
1541
+ return reject(err);
1542
+ }
1543
+
1544
+ resolve(result);
1318
1545
  };
1319
- /* c8 ignore stop */
1320
- socketPlain.once('error', socketPlainErrorHandler);
1546
+
1547
+ // Exposed so emitError() and close() can settle the upgrade through the same path.
1548
+ this._upgradeReject = settle;
1549
+
1550
+ // An error on either socket settles the upgrade, so settle() is the listener itself:
1551
+ // one function, one settlement, and removeListener() in settle() needs no separate
1552
+ // handler references. A TLS handshake failure (bad certificate, protocol mismatch)
1553
+ // is emitted on the new TLS socket rather than on the plain one, so both are covered.
1554
+ socketPlain.once('error', settle);
1321
1555
 
1322
1556
  /* c8 ignore start */ // UPGRADE_TIMEOUT is 10s; firing it deterministically would make the test suite hang
1323
1557
  this.upgradeTimeout = setTimeout(() => {
1324
- if (!this.upgrading) {
1325
- return;
1326
- }
1327
- this.closeAfter();
1328
1558
  let err = new Error('Failed to upgrade connection in required time');
1329
- err.tlsFailed = true;
1330
1559
  err.code = 'UPGRADE_TIMEOUT';
1331
- reject(err);
1560
+ settle(err);
1332
1561
  }, UPGRADE_TIMEOUT);
1333
1562
  /* c8 ignore stop */
1334
1563
 
1335
- // A TLS handshake failure (bad certificate, protocol mismatch, etc.) is emitted on the
1336
- // new TLS socket, not on the plain socket, so it must be handled here. Without this the
1337
- // upgrade promise would only settle via the timeout and connect() would reject with a
1338
- // generic "Unexpected close" instead of the actual TLS error.
1339
- const tlsSocketErrorHandler = err => {
1340
- clearTimeout(this.connectTimeout);
1341
- clearTimeout(this.upgradeTimeout);
1342
- /* c8 ignore start */ // the already-settled early return is a defensive double-fire guard, not separately exercised
1343
- if (!this.upgrading) {
1344
- // already settled
1345
- return;
1346
- }
1347
- /* c8 ignore stop */
1348
- this.upgrading = false;
1349
- err.tlsFailed = true;
1350
- this.clearSocketHandlers();
1351
- this.closeAfter();
1352
- reject(err);
1353
- };
1354
-
1355
1564
  this.upgrading = true;
1356
1565
  this.socket = tls.connect(opts, () => {
1357
1566
  try {
1358
- clearTimeout(this.upgradeTimeout);
1359
1567
  /* c8 ignore start */ // race: connection closed during the TLS handshake window
1360
1568
  if (this.isClosed) {
1361
- // not sure if this is possible?
1362
- return this.close();
1569
+ let err = new Error('Connection closed during TLS upgrade');
1570
+ err.code = 'NoConnection';
1571
+ return settle(err);
1363
1572
  }
1364
1573
  /* c8 ignore stop */
1365
1574
 
1366
1575
  // TLS handshake complete. Reconnect the now-encrypted socket
1367
1576
  // to the IMAP parser stream and record the cipher details.
1368
1577
  this.secureConnection = true;
1369
- this.upgrading = false;
1370
1578
  this.streamer.secureConnection = true;
1371
1579
  this.socket.pipe(this.streamer);
1372
1580
  /* c8 ignore next */ // an upgraded TLS socket always exposes getCipher(), so the false fallback is unreachable
@@ -1384,21 +1592,28 @@ class ImapFlow extends EventEmitter {
1384
1592
  });
1385
1593
  }
1386
1594
 
1387
- // Clean up the error handlers after successful upgrade
1388
- socketPlain.removeListener('error', socketPlainErrorHandler);
1389
- this.socket.removeListener('error', tlsSocketErrorHandler);
1595
+ // The plain socket is now only the TLS transport: drop its superseded
1596
+ // inactivity timer so no armed timer is left behind without a listener.
1597
+ if (typeof socketPlain.setTimeout === 'function') {
1598
+ socketPlain.setTimeout(0);
1599
+ }
1390
1600
 
1391
1601
  // Install the normal socket handlers only now that the handshake
1392
- // succeeded. Doing this during the handshake would leave both
1393
- // tlsSocketErrorHandler and the generic _socketError on the socket;
1394
- // a handshake 'error' would then fire BOTH (EventEmitter clones its
1395
- // listener array on emit), causing a duplicate error and a possible
1396
- // unhandled 'error' crash. Keeping tlsSocketErrorHandler as the sole
1602
+ // succeeded. Doing this during the handshake would leave both settle() and
1603
+ // the generic _socketError on the socket; a handshake 'error' would then fire
1604
+ // BOTH (EventEmitter clones its listener array on emit), causing a duplicate
1605
+ // error and a possible unhandled 'error' crash. Keeping settle() as the sole
1397
1606
  // listener until here guarantees a single error path for the upgrade.
1398
1607
  this.setSocketHandlers();
1399
1608
 
1400
- this._upgradeReject = null;
1401
- return resolve(true);
1609
+ // Arm the inactivity watchdog on the socket that now carries the session.
1610
+ // Without this a STARTTLS-upgraded connection has no watchdog at all: the
1611
+ // timer was armed on the plain socket, while the timeout listener lives on
1612
+ // the TLS socket.
1613
+ this.configureSocket(this.socket);
1614
+
1615
+ // settle() also removes the temporary handshake handlers
1616
+ settle(null, true);
1402
1617
  /* c8 ignore next 3 */ // defensive: the success callback body does not throw under normal operation
1403
1618
  } catch (ex) {
1404
1619
  this.emitError(ex);
@@ -1408,8 +1623,8 @@ class ImapFlow extends EventEmitter {
1408
1623
  // Registered after tls.connect (the TLS socket now exists). This is the ONLY
1409
1624
  // error listener during the handshake window; the generic handlers are installed
1410
1625
  // by setSocketHandlers() inside the success callback above, so a handshake error
1411
- // has a single error path (tlsSocketErrorHandler -> reject).
1412
- this.socket.once('error', tlsSocketErrorHandler);
1626
+ // has a single error path.
1627
+ this.socket.once('error', settle);
1413
1628
 
1414
1629
  this.writeSocket = this.socket;
1415
1630
  });
@@ -1541,6 +1756,9 @@ class ImapFlow extends EventEmitter {
1541
1756
  return;
1542
1757
  }
1543
1758
  this.state = this.states.AUTHENTICATED;
1759
+ // documented contract for the `authenticated` property: `true` when the
1760
+ // connection was authenticated by a PREAUTH greeting (no credentials known)
1761
+ this.authenticated = true;
1544
1762
  this.beginSession(err => {
1545
1763
  this.log.error({ err, cid: this.id });
1546
1764
  this.closeAfter();
@@ -1775,6 +1993,11 @@ class ImapFlow extends EventEmitter {
1775
1993
  return range;
1776
1994
  }
1777
1995
 
1996
+ // Timer process-liveness policy: connection establishment and greeting deadlines keep the
1997
+ // process alive, because a caller is waiting on connect() to settle. Background timers
1998
+ // (auto-IDLE, IDLE restart, fallback polling, throttle back-off, the held-lock diagnostic) are
1999
+ // unref'd, so an otherwise idle process is not held open by them. Every timer is still cleared
2000
+ // explicitly on close().
1778
2001
  autoidle() {
1779
2002
  clearTimeout(this.idleStartTimer);
1780
2003
  if (this.options.disableAutoIdle || this.state !== this.states.SELECTED) {
@@ -1783,6 +2006,7 @@ class ImapFlow extends EventEmitter {
1783
2006
  this.idleStartTimer = setTimeout(() => {
1784
2007
  this.idle().catch(err => this.log.warn({ err, cid: this.id }));
1785
2008
  }, 15 * 1000);
2009
+ unrefTimer(this.idleStartTimer);
1786
2010
  }
1787
2011
 
1788
2012
  // PUBLIC API METHODS
@@ -1803,6 +2027,11 @@ class ImapFlow extends EventEmitter {
1803
2027
  }
1804
2028
  this._connectCalled = true;
1805
2029
 
2030
+ // One deadline for the whole attempt, started before anything is resolved or negotiated.
2031
+ // Proxy DNS and proxy negotiation used to run entirely outside the timer, so a stalled
2032
+ // proxy could hang far beyond the documented connectionTimeout.
2033
+ let deadline = new ConnectionDeadline(this.options.connectionTimeout);
2034
+
1806
2035
  let connector = this.secureConnection ? tls : net;
1807
2036
 
1808
2037
  let opts = Object.assign(
@@ -1831,11 +2060,17 @@ class ImapFlow extends EventEmitter {
1831
2060
  let socket = false;
1832
2061
  if (this.options.proxy) {
1833
2062
  try {
1834
- socket = await proxyConnection(this.log, this.options.proxy, this.host, this.port);
2063
+ socket = await proxyConnection(this.log, this.options.proxy, this.host, this.port, { deadline });
1835
2064
  if (!socket) {
1836
2065
  throw new Error('Failed to setup proxy connection');
1837
2066
  }
1838
2067
  } catch (err) {
2068
+ if (err.code === 'CONNECT_TIMEOUT') {
2069
+ // The shared deadline expired during proxy setup. Report it as the documented
2070
+ // connection timeout rather than as a generic proxy failure.
2071
+ this.log.error({ err, cid: this.id });
2072
+ throw err;
2073
+ }
1839
2074
  let error = new Error('Failed to setup proxy connection');
1840
2075
  error.code = err.code || 'ProxyError';
1841
2076
  error._err = err;
@@ -1845,17 +2080,13 @@ class ImapFlow extends EventEmitter {
1845
2080
  }
1846
2081
 
1847
2082
  let connectPromise = new Promise((resolve, reject) => {
2083
+ // Whatever the proxy phase already used is gone from the budget
1848
2084
  this.connectTimeout = setTimeout(() => {
1849
- let err = new Error('Failed to establish connection in required time');
1850
- err.code = 'CONNECT_TIMEOUT';
1851
- err.details = {
1852
- /* c8 ignore next */ // firing the timeout with the default (large) value would hang the suite, so only the explicit-option path is tested
1853
- connectionTimeout: this.options.connectionTimeout || CONNECT_TIMEOUT
1854
- };
2085
+ let err = deadline.error();
1855
2086
  this.log.error({ err, cid: this.id });
1856
2087
  this.closeAfter();
1857
2088
  reject(err);
1858
- }, this.options.connectionTimeout || CONNECT_TIMEOUT);
2089
+ }, deadline.remaining());
1859
2090
 
1860
2091
  let onConnect = () => {
1861
2092
  try {
@@ -1865,8 +2096,7 @@ class ImapFlow extends EventEmitter {
1865
2096
  // (its "before connection setup" message no longer applies).
1866
2097
  detachEarlyErrorHandler(socket);
1867
2098
 
1868
- this.socket.setKeepAlive(true, 5 * 1000);
1869
- this.socket.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT);
2099
+ this.configureSocket(this.socket);
1870
2100
 
1871
2101
  this.greetingTimeout = setTimeout(() => {
1872
2102
  let err = new Error(
@@ -2017,8 +2247,23 @@ class ImapFlow extends EventEmitter {
2017
2247
  }
2018
2248
 
2019
2249
  this.usable = false;
2250
+ // close() takes over ownership of the idling state: dropping the session token means a
2251
+ // poll or IDLE that unwinds after this point sees that it no longer owns the flag and
2252
+ // leaves it alone (see claimIdling() in commands/idle.js).
2253
+ this._idleSession = null;
2020
2254
  this.idling = false;
2021
2255
 
2256
+ // An in-flight STARTTLS upgrade has to be settled through its own single settlement
2257
+ // path, otherwise the upgrade promise (and the session it belongs to) stays pending
2258
+ // for the lifetime of the process.
2259
+ if (typeof this._upgradeReject === 'function') {
2260
+ let reject = this._upgradeReject;
2261
+ this._upgradeReject = null;
2262
+ let err = new Error('Connection closed during TLS upgrade');
2263
+ err.code = 'NoConnection';
2264
+ reject(err);
2265
+ }
2266
+
2022
2267
  if (typeof this.initialReject === 'function' && !this.options.verifyOnly) {
2023
2268
  clearTimeout(this.greetingTimeout);
2024
2269
  let reject = this.initialReject;
@@ -2042,6 +2287,20 @@ class ImapFlow extends EventEmitter {
2042
2287
  this.preCheck().catch(err => this.log.warn({ err, cid: this.id }));
2043
2288
  }
2044
2289
 
2290
+ // Session-only public state must not survive the connection it describes: callers read
2291
+ // these properties in reconnect logic and would otherwise mistake cached objects for
2292
+ // live server state. Cleared during the first close only, so repeated close() calls
2293
+ // stay idempotent and cannot emit an event twice.
2294
+ // `byeReason` is deliberately kept: it explains why the session ended.
2295
+ let closedMailbox = false;
2296
+ if (!this.isClosed) {
2297
+ closedMailbox = this.mailbox;
2298
+ this.mailbox = false;
2299
+ this.currentSelectCommand = false;
2300
+ this.authenticated = false;
2301
+ this.preCheck = false;
2302
+ }
2303
+
2045
2304
  // Collect all pending requests to reject
2046
2305
  let pendingRequests = [];
2047
2306
 
@@ -2153,22 +2412,16 @@ class ImapFlow extends EventEmitter {
2153
2412
  if (this.isClosed) {
2154
2413
  return;
2155
2414
  }
2156
-
2157
- // Socket teardown order matters when compression is active:
2158
- // writeSocket may be a PassThrough (compression) or the raw socket (no compression).
2159
- // Destroy the underlying socket first, then writeSocket (if different).
2160
- // The second socket.destroy() block handles the case where writeSocket.destroy()
2161
- // did not also destroy the underlying socket.
2162
- if (this.socket && !this.socket.destroyed && this.writeSocket !== this.socket) {
2163
- try {
2164
- this.socket.destroy();
2165
- } catch (err) {
2166
- this.log.error({ err, cid: this.id });
2167
- }
2168
- }
2415
+ // Set before teardown so a socket event that re-enters close() during destruction
2416
+ // cannot run this block a second time.
2169
2417
  this.isClosed = true;
2170
2418
 
2171
- if (this.writeSocket && !this.writeSocket.destroyed) {
2419
+ // Socket teardown, in one documented order. Each stream owns and reports its own
2420
+ // lifecycle, so each is destroyed exactly once:
2421
+ // 1. the compression PassThrough (writeSocket), if compression replaced it
2422
+ // 2. the raw socket, which is also writeSocket when compression is not active
2423
+ // The compression streams themselves were destroyed above.
2424
+ if (this.writeSocket && this.writeSocket !== this.socket && !this.writeSocket.destroyed) {
2172
2425
  try {
2173
2426
  this.writeSocket.destroy();
2174
2427
  } catch (err) {
@@ -2176,7 +2429,7 @@ class ImapFlow extends EventEmitter {
2176
2429
  }
2177
2430
  }
2178
2431
 
2179
- if (this.socket && !this.socket.destroyed && this.writeSocket !== this.socket) {
2432
+ if (this.socket && !this.socket.destroyed) {
2180
2433
  try {
2181
2434
  this.socket.destroy();
2182
2435
  } catch (err) {
@@ -2197,7 +2450,19 @@ class ImapFlow extends EventEmitter {
2197
2450
  this._socketEnd = null;
2198
2451
  this._socketTimeout = null;
2199
2452
 
2200
- this.log.trace({ msg: 'Connection closed', cid: this.id });
2453
+ this.log.trace({
2454
+ msg: 'Connection closed',
2455
+ cid: this.id,
2456
+ ...(this._unknownTagCount ? { unknownTagCount: this._unknownTagCount } : {})
2457
+ });
2458
+
2459
+ // A mailbox that was still selected is now closed, so the transition is reported once,
2460
+ // whether the session ended with a clean logout or a lost transport. Emitted before
2461
+ // 'close' and only from the first close(), so no consumer sees it twice.
2462
+ if (closedMailbox) {
2463
+ this.emit('mailboxClose', closedMailbox);
2464
+ }
2465
+
2201
2466
  this.emit('close');
2202
2467
  } catch (ex) {
2203
2468
  // close failed
@@ -2243,6 +2508,7 @@ class ImapFlow extends EventEmitter {
2243
2508
  * @property {String} parentPath Same as `parent`, but as a complete string path (unicode string)
2244
2509
  * @property {Set<string>} flags a set of flags for this mailbox
2245
2510
  * @property {String} specialUse one of special-use flags (if applicable): "\All", "\Archive", "\Drafts", "\Flagged", "\Junk", "\Sent", "\Trash". Additionally INBOX has non-standard "\Inbox" flag set
2511
+ * @property {String} [specialUseSource] how `specialUse` was determined: `"user"` (from `specialUseHints`), `"extension"` (SPECIAL-USE or XLIST flag reported by the server) or `"name"` (matched against known localized folder names)
2246
2512
  * @property {Boolean} listed `true` if mailbox was found from the output of LIST command
2247
2513
  * @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers
2248
2514
  * @property {StatusObject} [status] If `statusQuery` was used, then this value includes the status response
@@ -2258,11 +2524,14 @@ class ImapFlow extends EventEmitter {
2258
2524
  * @property {Boolean} [statusQuery.uidValidity] if `true` request mailbox `UIDVALIDITY` value
2259
2525
  * @property {Boolean} [statusQuery.unseen] if `true` request count of unseen messages
2260
2526
  * @property {Boolean} [statusQuery.highestModseq] if `true` request last known modseq value
2527
+ * @property {Boolean} [statusQuery.size] if `true` request total mailbox size in octets (requires STATUS=SIZE or IMAP4rev2)
2528
+ * @property {Boolean} [statusQuery.deleted] if `true` request count of messages with \\Deleted flag (requires IMAP4rev2)
2261
2529
  * @property {Object} [specialUseHints] set specific paths as special use folders, this would override special use flags provided from the server
2262
2530
  * @property {String} [specialUseHints.sent] Path to "Sent Mail" folder
2263
2531
  * @property {String} [specialUseHints.trash] Path to "Trash" folder
2264
2532
  * @property {String} [specialUseHints.junk] Path to "Junk Mail" folder
2265
2533
  * @property {String} [specialUseHints.drafts] Path to "Drafts" folder
2534
+ * @property {String} [specialUseHints.archive] Path to "Archive" folder
2266
2535
  */
2267
2536
 
2268
2537
  /**
@@ -2462,6 +2731,8 @@ class ImapFlow extends EventEmitter {
2462
2731
  * @property {BigInt} [uidValidity] Mailbox `UIDVALIDITY` value
2463
2732
  * @property {Number} [unseen] Count of unseen messages
2464
2733
  * @property {BigInt} [highestModseq] Last known modseq value (if CONDSTORE extension is enabled)
2734
+ * @property {Number} [size] Total size of the mailbox in octets (only if requested and the server supports STATUS=SIZE or IMAP4rev2)
2735
+ * @property {Number} [deleted] Count of messages with \\Deleted flag (only if requested and IMAP4rev2 is active)
2465
2736
  */
2466
2737
 
2467
2738
  /**
@@ -2475,6 +2746,8 @@ class ImapFlow extends EventEmitter {
2475
2746
  * @param {Boolean} query.uidValidity if `true` request mailbox `UIDVALIDITY` value
2476
2747
  * @param {Boolean} query.unseen if `true` request count of unseen messages
2477
2748
  * @param {Boolean} query.highestModseq if `true` request last known modseq value
2749
+ * @param {Boolean} query.size if `true` request total mailbox size in octets (requires STATUS=SIZE or IMAP4rev2)
2750
+ * @param {Boolean} query.deleted if `true` request count of messages with \\Deleted flag (requires IMAP4rev2)
2478
2751
  * @returns {Promise<StatusObject>} status of the indicated mailbox
2479
2752
  *
2480
2753
  * @example
@@ -2969,6 +3242,7 @@ class ImapFlow extends EventEmitter {
2969
3242
  * @property {MessageStructureObject} [bodyStructure] message body structure
2970
3243
  * @property {Date} [internalDate] message internal date
2971
3244
  * @property {Map<string, Buffer>} [bodyParts] a Map of message body parts where key is requested part identifier and value is a Buffer
3245
+ * @property {Set<string>} [binaryParts] part identifiers from `bodyParts` that arrived via FETCH BINARY, i.e. with the content-transfer-encoding already decoded by the server
2972
3246
  * @property {Buffer} [headers] Requested header lines as Buffer
2973
3247
  */
2974
3248
 
@@ -3403,7 +3677,11 @@ class ImapFlow extends EventEmitter {
3403
3677
  // 4. Byte limiter (enforces maxBytes cap)
3404
3678
  // `stream` is the head of the pipeline (where raw chunks are written),
3405
3679
  // `output` is the tail (what the caller reads from).
3406
- switch (meta.encoding) {
3680
+ // Parts that arrived via FETCH BINARY (response.binaryParts) are already
3681
+ // decoded by the server - decoding again would corrupt the data, so stage 1
3682
+ // is skipped for them.
3683
+ let clientEncoding = response.binaryParts && response.binaryParts.has(part) ? false : meta.encoding;
3684
+ switch (clientEncoding) {
3407
3685
  case 'base64':
3408
3686
  output = stream = new libbase64.Decoder();
3409
3687
  break;
@@ -3713,7 +3991,10 @@ class ImapFlow extends EventEmitter {
3713
3991
  for (let part of Object.keys(data)) {
3714
3992
  let meta = data[part].meta;
3715
3993
 
3716
- switch (meta.encoding) {
3994
+ // parts that arrived via FETCH BINARY (response.binaryParts) are already
3995
+ // decoded by the server - decoding again would corrupt the data
3996
+ let clientEncoding = response.binaryParts && response.binaryParts.has(part) ? false : meta.encoding;
3997
+ switch (clientEncoding) {
3717
3998
  case 'base64':
3718
3999
  data[part].content = data[part].content ? libbase64.decode(data[part].content.toString()) : null;
3719
4000
  break;
@@ -3735,9 +4016,7 @@ class ImapFlow extends EventEmitter {
3735
4016
  }
3736
4017
 
3737
4018
  if (!this.socket || this.socket.destroyed) {
3738
- const error = new Error('Connection not available');
3739
- error.code = 'NoConnection';
3740
- throw error;
4019
+ throw this.createNoConnectionError();
3741
4020
  }
3742
4021
 
3743
4022
  clearTimeout(this.idleStartTimer);
@@ -3746,9 +4025,7 @@ class ImapFlow extends EventEmitter {
3746
4025
  await this.preCheck();
3747
4026
  }
3748
4027
 
3749
- let handler = this.commands.get(command);
3750
-
3751
- let result = await handler(this, ...args);
4028
+ let result = await this.runInternal(command, ...args);
3752
4029
 
3753
4030
  if (command !== 'IDLE') {
3754
4031
  // do not autostart IDLE, if IDLE itself was stopped
@@ -3758,6 +4035,33 @@ class ImapFlow extends EventEmitter {
3758
4035
  return result;
3759
4036
  }
3760
4037
 
4038
+ /**
4039
+ * Dispatches a command without the IDLE handshake that `run()` performs.
4040
+ *
4041
+ * Used by callers that already own the connection's idle state - fallback polling issues its
4042
+ * commands through here, because `run()` would await `preCheck()`, and the preCheck it would
4043
+ * await belongs to the very polling session making the call, so the session would cancel
4044
+ * itself. Auto-IDLE is not restarted either, for the same reason: the caller is the idle loop.
4045
+ *
4046
+ * @param {String} command Command name, as registered in the command registry.
4047
+ * @param {...*} args Arguments forwarded to the command implementation.
4048
+ * @returns {Promise<*>} Whatever the command implementation returns, or `false` for an
4049
+ * unknown command.
4050
+ */
4051
+ async runInternal(command, ...args) {
4052
+ command = command.toUpperCase();
4053
+ if (!this.commands.has(command)) {
4054
+ return false;
4055
+ }
4056
+
4057
+ if (!this.socket || this.socket.destroyed) {
4058
+ throw this.createNoConnectionError();
4059
+ }
4060
+
4061
+ let handler = this.commands.get(command);
4062
+ return await handler(this, ...args);
4063
+ }
4064
+
3761
4065
  // Mailbox lock queue processor. Implements a mutex pattern: only one lock
3762
4066
  // is active at a time. When the active lock is released, the next queued
3763
4067
  // lock is processed. The `processingLock` flag prevents concurrent runs
@@ -3815,6 +4119,7 @@ class ImapFlow extends EventEmitter {
3815
4119
  return;
3816
4120
  }
3817
4121
  lock.heldAt = Date.now();
4122
+ // Background diagnostic: must not keep the process alive on its own
3818
4123
  lock.heldWarnTimer = setTimeout(() => {
3819
4124
  lock.heldWarnTimer = null;
3820
4125
  this.log.warn({
@@ -3827,6 +4132,7 @@ class ImapFlow extends EventEmitter {
3827
4132
  cid: this.id
3828
4133
  });
3829
4134
  }, threshold);
4135
+ unrefTimer(lock.heldWarnTimer);
3830
4136
  };
3831
4137
 
3832
4138
  // release() is captured per-lock. It must only clear this.currentLock
@@ -4182,6 +4488,11 @@ class ImapFlow extends EventEmitter {
4182
4488
  /**
4183
4489
  * Mailbox was closed
4184
4490
  *
4491
+ * Emitted both when a selected mailbox is closed explicitly, by `mailboxClose()` or by
4492
+ * selecting a different mailbox, and when the connection itself goes away while a mailbox
4493
+ * was still selected, whether through a clean logout or a lost transport. The transition is
4494
+ * reported once per selected mailbox, before the `close` event.
4495
+ *
4185
4496
  * @event module:imapflow~ImapFlow#mailboxClose
4186
4497
  * @type {MailboxObject}
4187
4498
  * @example