imapflow 1.5.0 → 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 (36) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +15 -0
  3. package/CLAUDE.md +1 -1
  4. package/lib/commands/idle.js +197 -104
  5. package/lib/commands/list.js +15 -7
  6. package/lib/commands/quota.js +3 -0
  7. package/lib/commands/select.js +5 -0
  8. package/lib/commands/status.js +4 -0
  9. package/lib/connection-deadline.js +98 -0
  10. package/lib/handler/imap-compiler.js +8 -5
  11. package/lib/handler/imap-stream.js +141 -50
  12. package/lib/handler/limits.js +43 -0
  13. package/lib/handler/token-parser.js +31 -1
  14. package/lib/imap-flow.d.ts +33 -5
  15. package/lib/imap-flow.js +575 -281
  16. package/lib/proxy-connection.js +393 -98
  17. package/lib/special-use.js +660 -51
  18. package/lib/tools.js +17 -0
  19. package/package.json +2 -2
  20. package/test/commands-branches-test.js +17 -1
  21. package/test/commands-integration-test.js +24 -2
  22. package/test/fixtures/fake-timers.js +115 -0
  23. package/test/handler-branches-test.js +0 -25
  24. package/test/idle-polling-test.js +349 -0
  25. package/test/imap-flow-compress-test.js +12 -0
  26. package/test/imap-flow-coverage-test.js +3 -3
  27. package/test/imap-flow-internals-test.js +23 -0
  28. package/test/imap-flow-proxy-paths-test.js +151 -0
  29. package/test/imap-flow-secure-test.js +159 -0
  30. package/test/imap-flow-server-test.js +149 -0
  31. package/test/parser-limits-test.js +274 -0
  32. package/test/proxy-connection-test.js +553 -442
  33. package/test/reliability-improvements-test.js +87 -0
  34. package/test/special-use-test.js +337 -0
  35. package/test/tag-correlation-test.js +333 -0
  36. package/test/timer-policy-test.js +214 -0
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
  });
@@ -1778,6 +1993,11 @@ class ImapFlow extends EventEmitter {
1778
1993
  return range;
1779
1994
  }
1780
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().
1781
2001
  autoidle() {
1782
2002
  clearTimeout(this.idleStartTimer);
1783
2003
  if (this.options.disableAutoIdle || this.state !== this.states.SELECTED) {
@@ -1786,6 +2006,7 @@ class ImapFlow extends EventEmitter {
1786
2006
  this.idleStartTimer = setTimeout(() => {
1787
2007
  this.idle().catch(err => this.log.warn({ err, cid: this.id }));
1788
2008
  }, 15 * 1000);
2009
+ unrefTimer(this.idleStartTimer);
1789
2010
  }
1790
2011
 
1791
2012
  // PUBLIC API METHODS
@@ -1806,6 +2027,11 @@ class ImapFlow extends EventEmitter {
1806
2027
  }
1807
2028
  this._connectCalled = true;
1808
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
+
1809
2035
  let connector = this.secureConnection ? tls : net;
1810
2036
 
1811
2037
  let opts = Object.assign(
@@ -1834,11 +2060,17 @@ class ImapFlow extends EventEmitter {
1834
2060
  let socket = false;
1835
2061
  if (this.options.proxy) {
1836
2062
  try {
1837
- 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 });
1838
2064
  if (!socket) {
1839
2065
  throw new Error('Failed to setup proxy connection');
1840
2066
  }
1841
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
+ }
1842
2074
  let error = new Error('Failed to setup proxy connection');
1843
2075
  error.code = err.code || 'ProxyError';
1844
2076
  error._err = err;
@@ -1848,17 +2080,13 @@ class ImapFlow extends EventEmitter {
1848
2080
  }
1849
2081
 
1850
2082
  let connectPromise = new Promise((resolve, reject) => {
2083
+ // Whatever the proxy phase already used is gone from the budget
1851
2084
  this.connectTimeout = setTimeout(() => {
1852
- let err = new Error('Failed to establish connection in required time');
1853
- err.code = 'CONNECT_TIMEOUT';
1854
- err.details = {
1855
- /* c8 ignore next */ // firing the timeout with the default (large) value would hang the suite, so only the explicit-option path is tested
1856
- connectionTimeout: this.options.connectionTimeout || CONNECT_TIMEOUT
1857
- };
2085
+ let err = deadline.error();
1858
2086
  this.log.error({ err, cid: this.id });
1859
2087
  this.closeAfter();
1860
2088
  reject(err);
1861
- }, this.options.connectionTimeout || CONNECT_TIMEOUT);
2089
+ }, deadline.remaining());
1862
2090
 
1863
2091
  let onConnect = () => {
1864
2092
  try {
@@ -1868,8 +2096,7 @@ class ImapFlow extends EventEmitter {
1868
2096
  // (its "before connection setup" message no longer applies).
1869
2097
  detachEarlyErrorHandler(socket);
1870
2098
 
1871
- this.socket.setKeepAlive(true, 5 * 1000);
1872
- this.socket.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT);
2099
+ this.configureSocket(this.socket);
1873
2100
 
1874
2101
  this.greetingTimeout = setTimeout(() => {
1875
2102
  let err = new Error(
@@ -2020,8 +2247,23 @@ class ImapFlow extends EventEmitter {
2020
2247
  }
2021
2248
 
2022
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;
2023
2254
  this.idling = false;
2024
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
+
2025
2267
  if (typeof this.initialReject === 'function' && !this.options.verifyOnly) {
2026
2268
  clearTimeout(this.greetingTimeout);
2027
2269
  let reject = this.initialReject;
@@ -2045,6 +2287,20 @@ class ImapFlow extends EventEmitter {
2045
2287
  this.preCheck().catch(err => this.log.warn({ err, cid: this.id }));
2046
2288
  }
2047
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
+
2048
2304
  // Collect all pending requests to reject
2049
2305
  let pendingRequests = [];
2050
2306
 
@@ -2156,22 +2412,16 @@ class ImapFlow extends EventEmitter {
2156
2412
  if (this.isClosed) {
2157
2413
  return;
2158
2414
  }
2159
-
2160
- // Socket teardown order matters when compression is active:
2161
- // writeSocket may be a PassThrough (compression) or the raw socket (no compression).
2162
- // Destroy the underlying socket first, then writeSocket (if different).
2163
- // The second socket.destroy() block handles the case where writeSocket.destroy()
2164
- // did not also destroy the underlying socket.
2165
- if (this.socket && !this.socket.destroyed && this.writeSocket !== this.socket) {
2166
- try {
2167
- this.socket.destroy();
2168
- } catch (err) {
2169
- this.log.error({ err, cid: this.id });
2170
- }
2171
- }
2415
+ // Set before teardown so a socket event that re-enters close() during destruction
2416
+ // cannot run this block a second time.
2172
2417
  this.isClosed = true;
2173
2418
 
2174
- 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) {
2175
2425
  try {
2176
2426
  this.writeSocket.destroy();
2177
2427
  } catch (err) {
@@ -2179,7 +2429,7 @@ class ImapFlow extends EventEmitter {
2179
2429
  }
2180
2430
  }
2181
2431
 
2182
- if (this.socket && !this.socket.destroyed && this.writeSocket !== this.socket) {
2432
+ if (this.socket && !this.socket.destroyed) {
2183
2433
  try {
2184
2434
  this.socket.destroy();
2185
2435
  } catch (err) {
@@ -2200,7 +2450,19 @@ class ImapFlow extends EventEmitter {
2200
2450
  this._socketEnd = null;
2201
2451
  this._socketTimeout = null;
2202
2452
 
2203
- 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
+
2204
2466
  this.emit('close');
2205
2467
  } catch (ex) {
2206
2468
  // close failed
@@ -2246,6 +2508,7 @@ class ImapFlow extends EventEmitter {
2246
2508
  * @property {String} parentPath Same as `parent`, but as a complete string path (unicode string)
2247
2509
  * @property {Set<string>} flags a set of flags for this mailbox
2248
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)
2249
2512
  * @property {Boolean} listed `true` if mailbox was found from the output of LIST command
2250
2513
  * @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers
2251
2514
  * @property {StatusObject} [status] If `statusQuery` was used, then this value includes the status response
@@ -2268,6 +2531,7 @@ class ImapFlow extends EventEmitter {
2268
2531
  * @property {String} [specialUseHints.trash] Path to "Trash" folder
2269
2532
  * @property {String} [specialUseHints.junk] Path to "Junk Mail" folder
2270
2533
  * @property {String} [specialUseHints.drafts] Path to "Drafts" folder
2534
+ * @property {String} [specialUseHints.archive] Path to "Archive" folder
2271
2535
  */
2272
2536
 
2273
2537
  /**
@@ -3752,9 +4016,7 @@ class ImapFlow extends EventEmitter {
3752
4016
  }
3753
4017
 
3754
4018
  if (!this.socket || this.socket.destroyed) {
3755
- const error = new Error('Connection not available');
3756
- error.code = 'NoConnection';
3757
- throw error;
4019
+ throw this.createNoConnectionError();
3758
4020
  }
3759
4021
 
3760
4022
  clearTimeout(this.idleStartTimer);
@@ -3763,9 +4025,7 @@ class ImapFlow extends EventEmitter {
3763
4025
  await this.preCheck();
3764
4026
  }
3765
4027
 
3766
- let handler = this.commands.get(command);
3767
-
3768
- let result = await handler(this, ...args);
4028
+ let result = await this.runInternal(command, ...args);
3769
4029
 
3770
4030
  if (command !== 'IDLE') {
3771
4031
  // do not autostart IDLE, if IDLE itself was stopped
@@ -3775,6 +4035,33 @@ class ImapFlow extends EventEmitter {
3775
4035
  return result;
3776
4036
  }
3777
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
+
3778
4065
  // Mailbox lock queue processor. Implements a mutex pattern: only one lock
3779
4066
  // is active at a time. When the active lock is released, the next queued
3780
4067
  // lock is processed. The `processingLock` flag prevents concurrent runs
@@ -3832,6 +4119,7 @@ class ImapFlow extends EventEmitter {
3832
4119
  return;
3833
4120
  }
3834
4121
  lock.heldAt = Date.now();
4122
+ // Background diagnostic: must not keep the process alive on its own
3835
4123
  lock.heldWarnTimer = setTimeout(() => {
3836
4124
  lock.heldWarnTimer = null;
3837
4125
  this.log.warn({
@@ -3844,6 +4132,7 @@ class ImapFlow extends EventEmitter {
3844
4132
  cid: this.id
3845
4133
  });
3846
4134
  }, threshold);
4135
+ unrefTimer(lock.heldWarnTimer);
3847
4136
  };
3848
4137
 
3849
4138
  // release() is captured per-lock. It must only clear this.currentLock
@@ -4199,6 +4488,11 @@ class ImapFlow extends EventEmitter {
4199
4488
  /**
4200
4489
  * Mailbox was closed
4201
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
+ *
4202
4496
  * @event module:imapflow~ImapFlow#mailboxClose
4203
4497
  * @type {MailboxObject}
4204
4498
  * @example