imapflow 2.2.5 → 2.2.7

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 (74) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cjs/commands/append.js +10 -3
  3. package/dist/cjs/commands/authenticate.js +27 -18
  4. package/dist/cjs/commands/close.d.ts +12 -1
  5. package/dist/cjs/commands/close.js +4 -2
  6. package/dist/cjs/commands/copyuid-parser.js +16 -2
  7. package/dist/cjs/commands/delete.js +2 -1
  8. package/dist/cjs/commands/esearch-parser.js +8 -2
  9. package/dist/cjs/commands/fetch.js +35 -9
  10. package/dist/cjs/commands/id.js +8 -1
  11. package/dist/cjs/commands/idle.js +15 -5
  12. package/dist/cjs/commands/list.js +10 -1
  13. package/dist/cjs/commands/login.js +5 -1
  14. package/dist/cjs/commands/logout.js +7 -0
  15. package/dist/cjs/commands/namespace.js +7 -3
  16. package/dist/cjs/commands/quota.js +3 -1
  17. package/dist/cjs/commands/rename.js +2 -1
  18. package/dist/cjs/commands/select.js +5 -0
  19. package/dist/cjs/commands/status.js +6 -1
  20. package/dist/cjs/commands/store.d.ts +1 -1
  21. package/dist/cjs/commands/store.js +1 -2
  22. package/dist/cjs/download.js +82 -91
  23. package/dist/cjs/handler/imap-compiler.js +45 -29
  24. package/dist/cjs/handler/limits.d.ts +11 -0
  25. package/dist/cjs/handler/limits.js +16 -1
  26. package/dist/cjs/handler/parser-instance.d.ts +10 -0
  27. package/dist/cjs/handler/parser-instance.js +25 -10
  28. package/dist/cjs/handler/token-parser.js +162 -78
  29. package/dist/cjs/imap-flow.js +235 -89
  30. package/dist/cjs/package-info.d.ts +1 -1
  31. package/dist/cjs/package-info.js +1 -1
  32. package/dist/cjs/proxy-connection.js +7 -7
  33. package/dist/cjs/search-compiler.js +37 -16
  34. package/dist/cjs/special-use.js +10 -5
  35. package/dist/cjs/tools.d.ts +10 -1
  36. package/dist/cjs/tools.js +30 -3
  37. package/dist/cjs/types.d.ts +19 -4
  38. package/dist/esm/commands/append.js +11 -4
  39. package/dist/esm/commands/authenticate.js +28 -19
  40. package/dist/esm/commands/close.d.ts +12 -1
  41. package/dist/esm/commands/close.js +5 -3
  42. package/dist/esm/commands/copyuid-parser.js +16 -2
  43. package/dist/esm/commands/delete.js +2 -1
  44. package/dist/esm/commands/esearch-parser.js +8 -2
  45. package/dist/esm/commands/fetch.js +35 -9
  46. package/dist/esm/commands/id.js +9 -2
  47. package/dist/esm/commands/idle.js +16 -6
  48. package/dist/esm/commands/list.js +10 -1
  49. package/dist/esm/commands/login.js +6 -2
  50. package/dist/esm/commands/logout.js +7 -0
  51. package/dist/esm/commands/namespace.js +7 -3
  52. package/dist/esm/commands/quota.js +3 -1
  53. package/dist/esm/commands/rename.js +2 -1
  54. package/dist/esm/commands/select.js +5 -0
  55. package/dist/esm/commands/status.js +7 -2
  56. package/dist/esm/commands/store.d.ts +1 -1
  57. package/dist/esm/commands/store.js +1 -2
  58. package/dist/esm/download.js +82 -91
  59. package/dist/esm/handler/imap-compiler.js +45 -29
  60. package/dist/esm/handler/limits.d.ts +11 -0
  61. package/dist/esm/handler/limits.js +14 -0
  62. package/dist/esm/handler/parser-instance.d.ts +10 -0
  63. package/dist/esm/handler/parser-instance.js +25 -10
  64. package/dist/esm/handler/token-parser.js +163 -79
  65. package/dist/esm/imap-flow.js +235 -89
  66. package/dist/esm/package-info.d.ts +1 -1
  67. package/dist/esm/package-info.js +1 -1
  68. package/dist/esm/proxy-connection.js +7 -7
  69. package/dist/esm/search-compiler.js +37 -16
  70. package/dist/esm/special-use.js +10 -5
  71. package/dist/esm/tools.d.ts +10 -1
  72. package/dist/esm/tools.js +29 -3
  73. package/dist/esm/types.d.ts +19 -4
  74. package/package.json +3 -2
@@ -260,6 +260,7 @@ class ImapFlow extends node_events_1.EventEmitter {
260
260
  this.requestTagMap = new Map();
261
261
  this.requestQueue = [];
262
262
  this.currentRequest = false;
263
+ this.parkedRelease = null;
263
264
  this._unknownTagCount = 0;
264
265
  this._nextUnknownTagWarn = 1;
265
266
  this.writeBytesCounter = 0;
@@ -289,6 +290,8 @@ class ImapFlow extends node_events_1.EventEmitter {
289
290
  this.autoIdleDelay = normalizeAutoIdleDelay(this.options.autoIdleDelay, this.socketTimeout, this.log, this.id);
290
291
  this._lastPollAt = 0;
291
292
  this._openDownloads = 0;
293
+ this._recoveryPending = false;
294
+ this._processingResponse = false;
292
295
  this.missingIdleCommand = (this.options.missingIdleCommand || '').toString().toUpperCase().trim() || 'NOOP';
293
296
  this.disableBinary = !!this.options.disableBinary;
294
297
  this.idHashAlgorithm = this.options.idHashAlgorithm || 'md5';
@@ -296,6 +299,7 @@ class ImapFlow extends node_events_1.EventEmitter {
296
299
  this.skipListStatusArgs = false;
297
300
  this.skipListAuxArgs = false;
298
301
  this.skipLsub = false;
302
+ this.skipIdle = false;
299
303
  this.skipRev2 = !!this.options.disableIMAP4rev2;
300
304
  // Named error handler for proper cleanup. Certain error codes represent
301
305
  // expected socket/network issues (buffer exhaustion, connection reset, broken pipe,
@@ -444,15 +448,11 @@ class ImapFlow extends node_events_1.EventEmitter {
444
448
  /** @internal */
445
449
  async send(data) {
446
450
  if (this.state === this.states.LOGOUT) {
447
- // already logged out
448
- if (data.tag) {
449
- let request = this.requestTagMap.get(data.tag);
450
- if (request) {
451
- this.requestTagMap.delete(data.tag);
452
- request.reject(this.createNoConnectionError(false, { rejectedFrom: 'sendAfterLogout', command: request.command }));
453
- }
454
- }
455
- return;
451
+ // Already logged out. Thrown rather than rejected here so trySend() clears the
452
+ // request from currentRequest and goes on to the next one: a request left there
453
+ // never gets a tagged response, and everything queued behind it would wait for
454
+ // the socket watchdog.
455
+ throw this.createNoConnectionError(false, { rejectedFrom: 'sendAfterLogout', command: data.command });
456
456
  }
457
457
  // Classify before the first await. Every frame of this command - the command line and
458
458
  // any continuation write that follows it - belongs to it until the next send(), because
@@ -475,14 +475,16 @@ class ImapFlow extends node_events_1.EventEmitter {
475
475
  literalMinus: (0, tools_js_1.hasCapability)(this, 'LITERAL-') || this.capabilities.has('LITERAL+')
476
476
  });
477
477
  this.commandParts = compiled;
478
- // Compile again for logging with isLogging=true: masks sensitive values
479
- // like passwords while producing a human-readable command string
480
- let logCompiled = await (0, imap_handler_js_1.compiler)(data, {
481
- isLogging: true
482
- });
483
478
  /* c8 ignore next */ // send() is always invoked with a request object carrying options, so the {} fallback is unreachable
484
479
  let options = data.options || {};
485
- this.log.debug({ src: 'c', msg: logCompiled.toString(), cid: this.id, comment: options.comment });
480
+ // Compile again for logging with isLogging=true: masks sensitive values
481
+ // like passwords while producing a human-readable command string
482
+ if (this.isLogLevelEnabled('debug')) {
483
+ let logCompiled = await (0, imap_handler_js_1.compiler)(data, {
484
+ isLogging: true
485
+ });
486
+ this.log.debug({ src: 'c', msg: logCompiled.toString(), cid: this.id, comment: options.comment });
487
+ }
486
488
  // Send the first part (command text). If there are literal parts,
487
489
  // the server will respond with "+" continuations and reader() will
488
490
  // send each remaining part from this.commandParts.
@@ -525,7 +527,6 @@ class ImapFlow extends node_events_1.EventEmitter {
525
527
  // nothing reached the wire, so no tagged response ever clears it, and
526
528
  // every later command would queue behind it until the socket timeout.
527
529
  // Reject the failed command and keep draining the queue.
528
- this.commandParts = [];
529
530
  this.rejectCurrentRequest(err);
530
531
  }
531
532
  }
@@ -644,16 +645,53 @@ class ImapFlow extends node_events_1.EventEmitter {
644
645
  // a way that leaves the command's outcome unknown.
645
646
  /** @internal */
646
647
  rejectCurrentRequest(err) {
648
+ let request = this.takeCurrentRequest();
649
+ if (request) {
650
+ request.reject(err);
651
+ }
652
+ }
653
+ /**
654
+ * Takes the in-flight command off the connection: its tag map entry, and the literal parts it
655
+ * still had to send (a tagged NO before the "+" of a refused APPEND leaves them behind, and
656
+ * they must neither stay referenced nor answer a later continuation). The parts are dropped
657
+ * whether or not a command is still current, as a failed dispatch may have cleared it already.
658
+ *
659
+ * @returns The pending request entry, if the command was still current
660
+ * @internal
661
+ */
662
+ takeCurrentRequest() {
663
+ this.commandParts = [];
647
664
  if (!this.currentRequest) {
648
- return;
665
+ return undefined;
649
666
  }
650
667
  let tag = this.currentRequest.tag;
651
668
  this.currentRequest = false;
652
669
  let request = this.requestTagMap.get(tag);
653
- if (request) {
654
- this.requestTagMap.delete(tag);
655
- request.reject(err);
656
- }
670
+ this.requestTagMap.delete(tag);
671
+ return request;
672
+ }
673
+ /**
674
+ * Resolves a command's promise with its tagged response and parks the reader until the
675
+ * command's handler calls next(). The release is kept in parkedRelease so a handler that
676
+ * throws before calling next() can be unparked (see releaseOrphanedResponse()); every path
677
+ * that resolves a command this way goes through here for that reason.
678
+ *
679
+ * @param request - Pending request entry
680
+ * @param parsed - Parsed tagged response
681
+ * @param hasTrailingData - Whether more input was already buffered after this line
682
+ * @internal
683
+ */
684
+ resolveParked(request, parsed, hasTrailingData) {
685
+ return new Promise(resolve => {
686
+ let release = () => {
687
+ if (this.parkedRelease === release) {
688
+ this.parkedRelease = null;
689
+ }
690
+ resolve();
691
+ };
692
+ this.parkedRelease = release;
693
+ request.resolve({ response: parsed, next: release, hasTrailingData });
694
+ });
657
695
  }
658
696
  /**
659
697
  * Waits out a throttle back-off.
@@ -687,6 +725,10 @@ class ImapFlow extends node_events_1.EventEmitter {
687
725
  while ((data = this.streamer.read()) !== null) {
688
726
  let keepReading;
689
727
  try {
728
+ // The reader is parked here for as long as processing takes (a fetch consumer
729
+ // working on a row), so a socket that goes quiet meanwhile is this side's doing,
730
+ // see _socketTimeout
731
+ this._processingResponse = true;
690
732
  keepReading = await this.handleResponse(data);
691
733
  }
692
734
  catch (err) {
@@ -703,6 +745,7 @@ class ImapFlow extends node_events_1.EventEmitter {
703
745
  this.failProtocol(error);
704
746
  }
705
747
  finally {
748
+ this._processingResponse = false;
706
749
  this.releaseStreamData(data);
707
750
  }
708
751
  if (!keepReading) {
@@ -794,15 +837,14 @@ class ImapFlow extends node_events_1.EventEmitter {
794
837
  this.log.warn({ err, cid: this.id });
795
838
  }
796
839
  }
797
- let logCompiled = await (0, imap_handler_js_1.compiler)(parsed, {
798
- isLogging: true
799
- });
800
- if (/^\d+$/.test(parsed.command || '') && parsed.attributes && parsed.attributes[0] && parsed.attributes[0].value === 'FETCH') {
801
- // too many FETCH responses, might want to filter these out
802
- this.log.trace({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
803
- }
804
- else {
805
- this.log.debug({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
840
+ // FETCH responses are the bulk of a session, so they log at trace. The serialization is
841
+ // skipped when the level is off.
842
+ let logLevel = /^\d+$/.test(parsed.command || '') && parsed.attributes && parsed.attributes[0] && parsed.attributes[0].value === 'FETCH' ? 'trace' : 'debug';
843
+ if (this.isLogLevelEnabled(logLevel)) {
844
+ let logCompiled = await (0, imap_handler_js_1.compiler)(parsed, {
845
+ isLogging: true
846
+ });
847
+ this.log[logLevel]({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
806
848
  }
807
849
  // IMAP "+" (continuation request) handling. The server sends "+" in two cases:
808
850
  // 1. During IDLE or AUTHENTICATE, where a custom handler (onPlusTag) processes it
@@ -878,9 +920,7 @@ class ImapFlow extends node_events_1.EventEmitter {
878
920
  // and an entirely unknown tag is recorded but tolerated.
879
921
  if (parsed.tag && !['*', '+'].includes(parsed.tag)) {
880
922
  if (this.currentRequest && this.currentRequest.tag === parsed.tag && this.currentRequest.sent) {
881
- let request = this.requestTagMap.get(parsed.tag);
882
- this.requestTagMap.delete(parsed.tag);
883
- this.currentRequest = false;
923
+ let request = this.takeCurrentRequest();
884
924
  if (request) {
885
925
  await this.settleRequest(request, parsed, !!data.trailingAfterLine);
886
926
  }
@@ -940,7 +980,7 @@ class ImapFlow extends node_events_1.EventEmitter {
940
980
  case 'BYE':
941
981
  // hasTrailingData is forwarded so STARTTLS can detect a plaintext
942
982
  // injection (data buffered after the tagged OK, before the handshake).
943
- await new Promise(resolve => request.resolve({ response: parsed, next: resolve, hasTrailingData }));
983
+ await this.resolveParked(request, parsed, hasTrailingData);
944
984
  break;
945
985
  case 'NO':
946
986
  case 'BAD': {
@@ -968,7 +1008,7 @@ class ImapFlow extends node_events_1.EventEmitter {
968
1008
  // is told nothing else about it, so this entry is the only record that
969
1009
  // the response was truncated.
970
1010
  this.log.warn({ msg: 'Partial FETCH response', cid: this.id, err });
971
- await new Promise(resolve => request.resolve({ response: parsed, next: resolve }));
1011
+ await this.resolveParked(request, parsed);
972
1012
  break;
973
1013
  }
974
1014
  let throttleDelay = false;
@@ -1062,6 +1102,16 @@ class ImapFlow extends node_events_1.EventEmitter {
1062
1102
  if (typeof socket.setKeepAlive === 'function') {
1063
1103
  socket.setKeepAlive(true, 5 * 1000);
1064
1104
  }
1105
+ this.armSocketTimeout(socket);
1106
+ }
1107
+ /**
1108
+ * Arms the inactivity watchdog on a socket. Guarded the same way as the rest of
1109
+ * configureSocket(): other runtimes stub socket features.
1110
+ *
1111
+ * @param socket - The socket to arm
1112
+ * @internal
1113
+ */
1114
+ armSocketTimeout(socket) {
1065
1115
  if (typeof socket.setTimeout === 'function') {
1066
1116
  socket.setTimeout(this.socketTimeout);
1067
1117
  }
@@ -1101,25 +1151,50 @@ class ImapFlow extends node_events_1.EventEmitter {
1101
1151
  this._socketTimeout =
1102
1152
  this._socketTimeout ||
1103
1153
  (() => {
1104
- const err = new Error('Socket timeout');
1105
- err.code = 'ETIMEOUT';
1106
- const quietExpected = this.idling || this._openDownloads || this.currentLock;
1154
+ const timeoutError = () => {
1155
+ const err = new Error('Socket timeout');
1156
+ err.code = 'ETIMEOUT';
1157
+ return err;
1158
+ };
1159
+ if (!this.usable || !this.socket || this.socket.destroyed) {
1160
+ this.emitError(timeoutError());
1161
+ return;
1162
+ }
1163
+ if (this._processingResponse) {
1164
+ // The reader is parked processing a response (a fetch consumer taking its
1165
+ // time over a row), so nothing is being read: the quiet is this side's, and
1166
+ // the server is not overdue with anything. The timer is one-shot and nothing
1167
+ // resumes it until reading does, so it is re-armed for the next check.
1168
+ this.log.debug({ msg: 'Socket timeout while a response is being processed', cid: this.id });
1169
+ this.armSocketTimeout(this.socket);
1170
+ return;
1171
+ }
1172
+ // A throttle back-off is quiet by design, the NOOP below probes that the server
1173
+ // is still there
1174
+ const quietExpected = this.idling || this._openDownloads || this.currentLock || this._throttleWaits.size;
1107
1175
  const commandStuck = this.currentRequest && !(this.idling && this.currentRequest.command === 'IDLE');
1108
- if (quietExpected && !commandStuck) {
1109
- if (!this.usable || !this.socket || this.socket.destroyed) {
1110
- this.emitError(err);
1111
- return;
1112
- }
1113
- this.run('NOOP').catch(err => {
1176
+ // A recovery NOOP that has not settled by the next timeout means the peer is gone
1177
+ if (quietExpected && !commandStuck && !this._recoveryPending) {
1178
+ // The inactivity timer is one-shot, re-armed only by traffic. The NOOP can not
1179
+ // reach the wire while IDLE still waits for its continuation (DONE is sent
1180
+ // only after the "+"), so without re-arming it here a peer that went silent
1181
+ // right after IDLE would never time out again.
1182
+ this._recoveryPending = true;
1183
+ this.armSocketTimeout(this.socket);
1184
+ this.run('NOOP')
1185
+ .catch(err => {
1114
1186
  this.log.warn({ msg: 'Connection recovery failed after timeout', err, cid: this.id });
1115
1187
  if (!this.isClosed) {
1116
1188
  this.close();
1117
1189
  }
1190
+ })
1191
+ .finally(() => {
1192
+ this._recoveryPending = false;
1118
1193
  });
1119
1194
  }
1120
1195
  else {
1121
1196
  this.log.debug({ msg: 'Socket timeout', cid: this.id });
1122
- this.emitError(err);
1197
+ this.emitError(timeoutError());
1123
1198
  }
1124
1199
  });
1125
1200
  const socket = this.socket;
@@ -1349,8 +1424,15 @@ class ImapFlow extends node_events_1.EventEmitter {
1349
1424
  err.tlsFailed = true;
1350
1425
  throw err;
1351
1426
  }
1352
- // Opportunistic STARTTLS. But it's not possible right now.
1353
- // Attention: Could be a downgrade attack.
1427
+ // Opportunistic STARTTLS not offered. The capability list arrived in cleartext, so this
1428
+ // may be a downgrade attack: say so rather than fall back silently (see the doSTARTTLS
1429
+ // option for the policy).
1430
+ this.log.warn({
1431
+ msg: 'Server does not support STARTTLS, continuing over an unencrypted connection',
1432
+ host: this.host,
1433
+ port: this.port,
1434
+ cid: this.id
1435
+ });
1354
1436
  return false;
1355
1437
  }
1356
1438
  /**
@@ -1613,7 +1695,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1613
1695
  throw new errors_js_1.AuthenticationFailure('No matching authentication method');
1614
1696
  }
1615
1697
  /** @internal */
1616
- beginSession(onUnhandledError) {
1698
+ beginSession() {
1617
1699
  (0, tools_js_1.clearTimer)(this.greetingTimeout);
1618
1700
  this.greetingReceived = true;
1619
1701
  this.untaggedHandlers.OK = null;
@@ -1632,15 +1714,21 @@ class ImapFlow extends node_events_1.EventEmitter {
1632
1714
  }
1633
1715
  })
1634
1716
  .catch(err => {
1635
- this.log.error({ err, cid: this.id });
1717
+ // The transport goes with the failed attempt: the instance can not be reused, and
1718
+ // an open socket would only trip the inactivity watchdog later
1719
+ this.closeAfter();
1636
1720
  if (typeof this.initialReject === 'function') {
1721
+ this.log.error({ err, cid: this.id });
1637
1722
  (0, tools_js_1.clearTimer)(this.greetingTimeout);
1638
1723
  let reject = this.initialReject;
1639
1724
  this.initialResolve = false;
1640
1725
  this.initialReject = false;
1641
1726
  return reject(err);
1642
1727
  }
1643
- onUnhandledError(err);
1728
+ // connect() was already settled by whatever took the connection down (a socket
1729
+ // error or close rejected it first), so this failure is a consequence of that.
1730
+ // A second 'error' event for it would throw when no listener is attached.
1731
+ (0, tools_js_1.logConnectionError)(this, 'Session setup failed', err);
1644
1732
  });
1645
1733
  }
1646
1734
  /** @internal */
@@ -1648,8 +1736,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1648
1736
  this.greeting = (0, tools_js_1.getTextValues)(message.attributes)
1649
1737
  .filter(entry => entry)
1650
1738
  .join('');
1651
- // ALWAYS emit the error so users can handle it
1652
- this.beginSession(err => this.emitError(err));
1739
+ this.beginSession();
1653
1740
  }
1654
1741
  /** @internal */
1655
1742
  async initialPREAUTH() {
@@ -1660,10 +1747,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1660
1747
  // documented contract for the `authenticated` property: `true` when the
1661
1748
  // connection was authenticated by a PREAUTH greeting (no credentials known)
1662
1749
  this.authenticated = true;
1663
- this.beginSession(err => {
1664
- this.log.error({ err, cid: this.id });
1665
- this.closeAfter();
1666
- });
1750
+ this.beginSession();
1667
1751
  }
1668
1752
  /** @internal */
1669
1753
  async serverBye(parsed) {
@@ -1675,10 +1759,13 @@ class ImapFlow extends node_events_1.EventEmitter {
1675
1759
  this.byeReason = reason || 'Server closed connection';
1676
1760
  this.untaggedHandlers.BYE = null;
1677
1761
  this.state = this.states.LOGOUT;
1678
- // A BYE greeting rejects the connection outright. Do not wait for the server to close
1679
- // the socket: one that keeps it open would leave connect() pending until the greeting
1680
- // timeout.
1681
- if (!this.greetingReceived) {
1762
+ // BYE means the server is about to close the connection (RFC 9051 7.1.5), so the socket
1763
+ // is not waited out: a BYE greeting kept open would leave connect() pending until the
1764
+ // greeting timeout, and an unsolicited BYE whose FIN never arrives would leave the
1765
+ // in-flight command and the queue behind it waiting for the socket watchdog. The BYE
1766
+ // that answers a LOGOUT is the exception: its tagged completion closes the connection.
1767
+ const answersLogout = !!this.currentRequest && this.currentRequest.command.toUpperCase() === 'LOGOUT';
1768
+ if (!this.greetingReceived || !answersLogout) {
1682
1769
  this.closeAfter();
1683
1770
  }
1684
1771
  }
@@ -1740,7 +1827,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1740
1827
  // keep exists up to date
1741
1828
  let prevCount = this.mailbox.exists;
1742
1829
  this.mailbox.exists = count;
1743
- this.emit('exists', {
1830
+ (0, tools_js_1.emitSafe)(this, 'exists', {
1744
1831
  path: this.mailbox.path,
1745
1832
  count,
1746
1833
  prevCount
@@ -1751,7 +1838,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1751
1838
  /** @internal */
1752
1839
  async notifyExpunge(payload) {
1753
1840
  if (typeof this.options.expungeHandler !== 'function') {
1754
- this.emit('expunge', payload);
1841
+ (0, tools_js_1.emitSafe)(this, 'expunge', payload);
1755
1842
  return;
1756
1843
  }
1757
1844
  try {
@@ -1838,7 +1925,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1838
1925
  if (message.flagColor) {
1839
1926
  updateEvent.flagColor = message.flagColor;
1840
1927
  }
1841
- this.emit('flags', updateEvent);
1928
+ (0, tools_js_1.emitSafe)(this, 'flags', updateEvent);
1842
1929
  }
1843
1930
  }
1844
1931
  /** @internal */
@@ -2022,6 +2109,12 @@ class ImapFlow extends node_events_1.EventEmitter {
2022
2109
  reject(err);
2023
2110
  }, deadline.remaining());
2024
2111
  let onConnect = () => {
2112
+ // close() may have run between the socket assignment and this callback (the
2113
+ // cleartext proxy path defers it): connect() is already rejected and the socket
2114
+ // gone, so arming the greeting timer now would only fire a stray GREETING_TIMEOUT
2115
+ if (this.isClosed || !this.socket) {
2116
+ return;
2117
+ }
2025
2118
  try {
2026
2119
  (0, tools_js_1.clearTimer)(this.connectTimeout);
2027
2120
  // ImapFlow now owns the socket; drop the proxy's early error handler
@@ -2156,9 +2249,13 @@ class ImapFlow extends node_events_1.EventEmitter {
2156
2249
  this.idling = false;
2157
2250
  this.closeConnectSteps();
2158
2251
  if (typeof this.preCheck === 'function') {
2159
- // Runs while the connection is being torn down, so the rejection this sees is
2160
- // almost always the NoConnection close() is about to raise itself.
2161
- this.preCheck().catch(err => (0, tools_js_1.logConnectionError)(this, 'Failed to break IDLE while closing', err));
2252
+ // Taken off the connection first: breaking IDLE writes DONE, a write on a dead
2253
+ // transport calls close(), and close() must not find this function here and
2254
+ // re-enter it. The rejection this sees is almost always the NoConnection close()
2255
+ // is about to raise itself.
2256
+ let preCheck = this.preCheck;
2257
+ this.preCheck = false;
2258
+ preCheck().catch(err => (0, tools_js_1.logConnectionError)(this, 'Failed to break IDLE while closing', err));
2162
2259
  }
2163
2260
  // Session-only public state must not survive the connection it describes: callers read
2164
2261
  // these properties in reconnect logic and would otherwise mistake cached objects for
@@ -2278,6 +2375,7 @@ class ImapFlow extends node_events_1.EventEmitter {
2278
2375
  closeRequests() {
2279
2376
  // Collect all pending requests to reject
2280
2377
  let pendingRequests = [];
2378
+ this.commandParts = [];
2281
2379
  // reject command that is currently processed
2282
2380
  if (this.currentRequest && this.requestTagMap.has(this.currentRequest.tag)) {
2283
2381
  let tag = this.currentRequest.tag;
@@ -3136,7 +3234,34 @@ class ImapFlow extends node_events_1.EventEmitter {
3136
3234
  throw this.createNoConnectionError(false, { rejectedFrom: 'noSocket', command });
3137
3235
  }
3138
3236
  let handler = this.commands.get(command);
3139
- return await handler(this, ...args);
3237
+ try {
3238
+ return await handler(this, ...args);
3239
+ }
3240
+ finally {
3241
+ this.releaseOrphanedResponse(command);
3242
+ }
3243
+ }
3244
+ /**
3245
+ * Safety net for the `next()` contract of settleRequest(): a command handler that throws or
3246
+ * returns between the resolution of its exec() and the `next()` call would leave the reader
3247
+ * loop parked, stalling every later command until the socket timeout. Checked one macrotask
3248
+ * after the handler settled, so a concurrently running handler whose response has just been
3249
+ * resolved gets to release it itself first.
3250
+ *
3251
+ * @param command Command name, for the log entry.
3252
+ * @internal
3253
+ */
3254
+ releaseOrphanedResponse(command) {
3255
+ let release = this.parkedRelease;
3256
+ if (!release) {
3257
+ return;
3258
+ }
3259
+ setImmediate(() => {
3260
+ if (this.parkedRelease === release) {
3261
+ this.log.warn({ msg: 'Command handler did not release its response', command, cid: this.id });
3262
+ release();
3263
+ }
3264
+ });
3140
3265
  }
3141
3266
  // Mailbox lock queue processor. Implements a mutex pattern: only one lock
3142
3267
  // is active at a time. When the active lock is released, the next queued
@@ -3272,6 +3397,11 @@ class ImapFlow extends node_events_1.EventEmitter {
3272
3397
  try {
3273
3398
  // Need to SELECT/EXAMINE a different mailbox
3274
3399
  await this.mailboxOpen(path, options);
3400
+ if (!this.mailbox) {
3401
+ // mailboxOpen() resolves with nothing when the connection is no longer
3402
+ // authenticated (a BYE arrived meanwhile): no mailbox, so no lock on it
3403
+ throw this.createNoConnectionError(false, { rejectedFrom: 'mailboxLock', path });
3404
+ }
3275
3405
  this.log.trace({
3276
3406
  msg: 'Mailbox lock acquired [selected]',
3277
3407
  path,
@@ -3382,35 +3512,36 @@ class ImapFlow extends node_events_1.EventEmitter {
3382
3512
  // {logger:false} never consults mainLogger, so it does not create the default logger
3383
3513
  mainLogger = (0, logger_js_1.createConnectionLogger)({ cid: this.id, logRaw: this.options.logRaw });
3384
3514
  }
3515
+ // Whether the configured logger takes entries at this level: a disabled logger takes
3516
+ // none, a plain object of level methods the levels it has methods for, plus error and
3517
+ // fatal, which fall back to the console. The level methods and isLevelEnabled() below
3518
+ // share this one rule.
3519
+ const reachesLogger = (level) => this.options.logger !== false && (typeof mainLogger[level] === 'function' || level === 'error' || level === 'fatal');
3385
3520
  let synteticLogger = {};
3386
3521
  let levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
3387
3522
  for (let level of levels) {
3388
3523
  synteticLogger[level] = (...args) => {
3389
- // using {logger:false} disables logging
3390
- if (this.options.logger !== false) {
3524
+ if (reachesLogger(level)) {
3391
3525
  const logMethod = mainLogger[level];
3392
- if (typeof logMethod !== 'function') {
3393
- // we are checking to make sure the level is supported.
3394
- // if it isn't supported but the level is error or fatal, log to console anyway.
3395
- if (level === 'fatal' || level === 'error') {
3396
- let entry = args[0];
3397
- try {
3398
- if (entry && typeof entry === 'object' && entry.err) {
3399
- entry = Object.assign({}, entry, { err: flattenLoggedError(entry.err) });
3400
- }
3401
- console.error(JSON.stringify(entry));
3402
- }
3403
- catch {
3404
- // Serializing failed (a circular structure, a BigInt, a throwing
3405
- // getter). This fallback exists so an error is never lost, so hand
3406
- // the entry to console.error itself - it inspects rather than
3407
- // serializes, and handles all three - instead of dropping it.
3408
- console.error(entry);
3409
- }
3410
- }
3526
+ if (typeof logMethod === 'function') {
3527
+ logMethod.apply(mainLogger, args);
3411
3528
  }
3412
3529
  else {
3413
- logMethod.apply(mainLogger, args);
3530
+ // error or fatal without a method of its own: the console, so it is never lost
3531
+ let entry = args[0];
3532
+ try {
3533
+ if (entry && typeof entry === 'object' && entry.err) {
3534
+ entry = Object.assign({}, entry, { err: flattenLoggedError(entry.err) });
3535
+ }
3536
+ console.error(JSON.stringify(entry));
3537
+ }
3538
+ catch {
3539
+ // Serializing failed (a circular structure, a BigInt, a throwing
3540
+ // getter). This fallback exists so an error is never lost, so hand
3541
+ // the entry to console.error itself - it inspects rather than
3542
+ // serializes, and handles all three - instead of dropping it.
3543
+ console.error(entry);
3544
+ }
3414
3545
  }
3415
3546
  }
3416
3547
  if (this.emitLogs && args && args[0] && typeof args[0] === 'object') {
@@ -3433,8 +3564,23 @@ class ImapFlow extends node_events_1.EventEmitter {
3433
3564
  }
3434
3565
  };
3435
3566
  }
3567
+ // Whether an entry at this level reaches anyone, so the caller can skip building an
3568
+ // expensive one (a response serialized for the log) that nobody would see. 'log' events
3569
+ // carry every entry whatever the logger does with it, and a pino-like logger knows its
3570
+ // own threshold.
3571
+ synteticLogger.isLevelEnabled = (level) => this.emitLogs || (typeof mainLogger.isLevelEnabled === 'function' ? !!mainLogger.isLevelEnabled(level) : reachesLogger(level));
3436
3572
  return synteticLogger;
3437
3573
  }
3574
+ /**
3575
+ * Whether a log entry at this level reaches anyone, so the caller can skip building an
3576
+ * expensive one. A logger assigned to `log` from outside may lack the method, and then
3577
+ * every level counts as enabled.
3578
+ *
3579
+ * @internal
3580
+ */
3581
+ isLogLevelEnabled(level) {
3582
+ return typeof this.log.isLevelEnabled === 'function' ? this.log.isLevelEnabled(level) : true;
3583
+ }
3438
3584
  /**
3439
3585
  * Detaches sockets from the IMAP pipeline. Useful for upgrading the connection
3440
3586
  * (e.g., STARTTLS) or transferring socket ownership. Call it while the connection is not
@@ -1,3 +1,3 @@
1
1
  export declare const name = "imapflow";
2
- export declare const version = "2.2.5";
2
+ export declare const version = "2.2.7";
3
3
  export declare const homepage = "https://imapflow.com/";
@@ -3,5 +3,5 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.homepage = exports.version = exports.name = void 0;
5
5
  exports.name = "imapflow";
6
- exports.version = "2.2.5";
6
+ exports.version = "2.2.7";
7
7
  exports.homepage = "https://imapflow.com/";
@@ -241,11 +241,11 @@ const httpConnect = async ({ logger, proxyUrl, secureProxy, proxyHost, proxyPort
241
241
  * @returns An IPv4 address.
242
242
  */
243
243
  const resolveIPv4 = async (hostname, deadline) => {
244
- let addresses = await deadline.race(node_dns_1.default.promises.resolve4(hostname));
245
- if (!addresses || !addresses.length) {
246
- throw proxyError(`Could not resolve an IPv4 address for ${hostname}`, 'EPROXY');
247
- }
248
- return addresses[0];
244
+ // lookup() goes through the system resolver (getaddrinfo), so hosts files and mDNS names
245
+ // resolve the same way they would for a direct connection. It rejects (ENOTFOUND) rather
246
+ // than returning nothing when the name has no IPv4 address
247
+ let { address } = await deadline.race(node_dns_1.default.promises.lookup(hostname, { family: 4 }));
248
+ return address;
249
249
  };
250
250
  /**
251
251
  * Establishes a tunnel through a SOCKS proxy.
@@ -289,8 +289,8 @@ const socksConnect = async ({ logger, proxyUrl, protocol, proxyHost, proxyPort,
289
289
  set_tcp_nodelay: true
290
290
  };
291
291
  if (proxyUrl.username || proxyUrl.password) {
292
- connectionOpts.proxy.userId = proxyUrl.username;
293
- connectionOpts.proxy.password = proxyUrl.password;
292
+ connectionOpts.proxy.userId = decodeUserInfo(proxyUrl.username);
293
+ connectionOpts.proxy.password = decodeUserInfo(proxyUrl.password);
294
294
  }
295
295
  // The dependency treats a zero timeout as its own 30 second default, so only a strictly
296
296
  // positive remaining budget may be passed.