imapflow 2.2.6 → 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 (72) hide show
  1. package/CHANGELOG.md +9 -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/delete.js +2 -1
  7. package/dist/cjs/commands/esearch-parser.js +8 -2
  8. package/dist/cjs/commands/fetch.js +35 -9
  9. package/dist/cjs/commands/id.js +8 -1
  10. package/dist/cjs/commands/idle.js +15 -5
  11. package/dist/cjs/commands/list.js +10 -1
  12. package/dist/cjs/commands/login.js +5 -1
  13. package/dist/cjs/commands/logout.js +7 -0
  14. package/dist/cjs/commands/namespace.js +7 -3
  15. package/dist/cjs/commands/quota.js +3 -1
  16. package/dist/cjs/commands/rename.js +2 -1
  17. package/dist/cjs/commands/select.js +5 -0
  18. package/dist/cjs/commands/status.js +6 -1
  19. package/dist/cjs/commands/store.d.ts +1 -1
  20. package/dist/cjs/commands/store.js +1 -2
  21. package/dist/cjs/download.js +82 -91
  22. package/dist/cjs/handler/imap-compiler.js +19 -10
  23. package/dist/cjs/handler/limits.d.ts +11 -0
  24. package/dist/cjs/handler/limits.js +16 -1
  25. package/dist/cjs/handler/parser-instance.d.ts +10 -0
  26. package/dist/cjs/handler/parser-instance.js +25 -10
  27. package/dist/cjs/handler/token-parser.js +36 -28
  28. package/dist/cjs/imap-flow.js +235 -89
  29. package/dist/cjs/package-info.d.ts +1 -1
  30. package/dist/cjs/package-info.js +1 -1
  31. package/dist/cjs/proxy-connection.js +7 -7
  32. package/dist/cjs/search-compiler.js +30 -13
  33. package/dist/cjs/special-use.js +10 -5
  34. package/dist/cjs/tools.d.ts +10 -1
  35. package/dist/cjs/tools.js +30 -3
  36. package/dist/cjs/types.d.ts +19 -4
  37. package/dist/esm/commands/append.js +11 -4
  38. package/dist/esm/commands/authenticate.js +28 -19
  39. package/dist/esm/commands/close.d.ts +12 -1
  40. package/dist/esm/commands/close.js +5 -3
  41. package/dist/esm/commands/delete.js +2 -1
  42. package/dist/esm/commands/esearch-parser.js +8 -2
  43. package/dist/esm/commands/fetch.js +35 -9
  44. package/dist/esm/commands/id.js +9 -2
  45. package/dist/esm/commands/idle.js +16 -6
  46. package/dist/esm/commands/list.js +10 -1
  47. package/dist/esm/commands/login.js +6 -2
  48. package/dist/esm/commands/logout.js +7 -0
  49. package/dist/esm/commands/namespace.js +7 -3
  50. package/dist/esm/commands/quota.js +3 -1
  51. package/dist/esm/commands/rename.js +2 -1
  52. package/dist/esm/commands/select.js +5 -0
  53. package/dist/esm/commands/status.js +7 -2
  54. package/dist/esm/commands/store.d.ts +1 -1
  55. package/dist/esm/commands/store.js +1 -2
  56. package/dist/esm/download.js +82 -91
  57. package/dist/esm/handler/imap-compiler.js +19 -10
  58. package/dist/esm/handler/limits.d.ts +11 -0
  59. package/dist/esm/handler/limits.js +14 -0
  60. package/dist/esm/handler/parser-instance.d.ts +10 -0
  61. package/dist/esm/handler/parser-instance.js +25 -10
  62. package/dist/esm/handler/token-parser.js +37 -29
  63. package/dist/esm/imap-flow.js +235 -89
  64. package/dist/esm/package-info.d.ts +1 -1
  65. package/dist/esm/package-info.js +1 -1
  66. package/dist/esm/proxy-connection.js +7 -7
  67. package/dist/esm/search-compiler.js +30 -13
  68. package/dist/esm/special-use.js +10 -5
  69. package/dist/esm/tools.d.ts +10 -1
  70. package/dist/esm/tools.js +29 -3
  71. package/dist/esm/types.d.ts +19 -4
  72. package/package.json +1 -1
@@ -219,6 +219,7 @@ export class ImapFlow extends EventEmitter {
219
219
  this.requestTagMap = new Map();
220
220
  this.requestQueue = [];
221
221
  this.currentRequest = false;
222
+ this.parkedRelease = null;
222
223
  this._unknownTagCount = 0;
223
224
  this._nextUnknownTagWarn = 1;
224
225
  this.writeBytesCounter = 0;
@@ -248,6 +249,8 @@ export class ImapFlow extends EventEmitter {
248
249
  this.autoIdleDelay = normalizeAutoIdleDelay(this.options.autoIdleDelay, this.socketTimeout, this.log, this.id);
249
250
  this._lastPollAt = 0;
250
251
  this._openDownloads = 0;
252
+ this._recoveryPending = false;
253
+ this._processingResponse = false;
251
254
  this.missingIdleCommand = (this.options.missingIdleCommand || '').toString().toUpperCase().trim() || 'NOOP';
252
255
  this.disableBinary = !!this.options.disableBinary;
253
256
  this.idHashAlgorithm = this.options.idHashAlgorithm || 'md5';
@@ -255,6 +258,7 @@ export class ImapFlow extends EventEmitter {
255
258
  this.skipListStatusArgs = false;
256
259
  this.skipListAuxArgs = false;
257
260
  this.skipLsub = false;
261
+ this.skipIdle = false;
258
262
  this.skipRev2 = !!this.options.disableIMAP4rev2;
259
263
  // Named error handler for proper cleanup. Certain error codes represent
260
264
  // expected socket/network issues (buffer exhaustion, connection reset, broken pipe,
@@ -403,15 +407,11 @@ export class ImapFlow extends EventEmitter {
403
407
  /** @internal */
404
408
  async send(data) {
405
409
  if (this.state === this.states.LOGOUT) {
406
- // already logged out
407
- if (data.tag) {
408
- let request = this.requestTagMap.get(data.tag);
409
- if (request) {
410
- this.requestTagMap.delete(data.tag);
411
- request.reject(this.createNoConnectionError(false, { rejectedFrom: 'sendAfterLogout', command: request.command }));
412
- }
413
- }
414
- return;
410
+ // Already logged out. Thrown rather than rejected here so trySend() clears the
411
+ // request from currentRequest and goes on to the next one: a request left there
412
+ // never gets a tagged response, and everything queued behind it would wait for
413
+ // the socket watchdog.
414
+ throw this.createNoConnectionError(false, { rejectedFrom: 'sendAfterLogout', command: data.command });
415
415
  }
416
416
  // Classify before the first await. Every frame of this command - the command line and
417
417
  // any continuation write that follows it - belongs to it until the next send(), because
@@ -434,14 +434,16 @@ export class ImapFlow extends EventEmitter {
434
434
  literalMinus: hasCapability(this, 'LITERAL-') || this.capabilities.has('LITERAL+')
435
435
  });
436
436
  this.commandParts = compiled;
437
- // Compile again for logging with isLogging=true: masks sensitive values
438
- // like passwords while producing a human-readable command string
439
- let logCompiled = await compiler(data, {
440
- isLogging: true
441
- });
442
437
  /* c8 ignore next */ // send() is always invoked with a request object carrying options, so the {} fallback is unreachable
443
438
  let options = data.options || {};
444
- this.log.debug({ src: 'c', msg: logCompiled.toString(), cid: this.id, comment: options.comment });
439
+ // Compile again for logging with isLogging=true: masks sensitive values
440
+ // like passwords while producing a human-readable command string
441
+ if (this.isLogLevelEnabled('debug')) {
442
+ let logCompiled = await compiler(data, {
443
+ isLogging: true
444
+ });
445
+ this.log.debug({ src: 'c', msg: logCompiled.toString(), cid: this.id, comment: options.comment });
446
+ }
445
447
  // Send the first part (command text). If there are literal parts,
446
448
  // the server will respond with "+" continuations and reader() will
447
449
  // send each remaining part from this.commandParts.
@@ -484,7 +486,6 @@ export class ImapFlow extends EventEmitter {
484
486
  // nothing reached the wire, so no tagged response ever clears it, and
485
487
  // every later command would queue behind it until the socket timeout.
486
488
  // Reject the failed command and keep draining the queue.
487
- this.commandParts = [];
488
489
  this.rejectCurrentRequest(err);
489
490
  }
490
491
  }
@@ -603,16 +604,53 @@ export class ImapFlow extends EventEmitter {
603
604
  // a way that leaves the command's outcome unknown.
604
605
  /** @internal */
605
606
  rejectCurrentRequest(err) {
607
+ let request = this.takeCurrentRequest();
608
+ if (request) {
609
+ request.reject(err);
610
+ }
611
+ }
612
+ /**
613
+ * Takes the in-flight command off the connection: its tag map entry, and the literal parts it
614
+ * still had to send (a tagged NO before the "+" of a refused APPEND leaves them behind, and
615
+ * they must neither stay referenced nor answer a later continuation). The parts are dropped
616
+ * whether or not a command is still current, as a failed dispatch may have cleared it already.
617
+ *
618
+ * @returns The pending request entry, if the command was still current
619
+ * @internal
620
+ */
621
+ takeCurrentRequest() {
622
+ this.commandParts = [];
606
623
  if (!this.currentRequest) {
607
- return;
624
+ return undefined;
608
625
  }
609
626
  let tag = this.currentRequest.tag;
610
627
  this.currentRequest = false;
611
628
  let request = this.requestTagMap.get(tag);
612
- if (request) {
613
- this.requestTagMap.delete(tag);
614
- request.reject(err);
615
- }
629
+ this.requestTagMap.delete(tag);
630
+ return request;
631
+ }
632
+ /**
633
+ * Resolves a command's promise with its tagged response and parks the reader until the
634
+ * command's handler calls next(). The release is kept in parkedRelease so a handler that
635
+ * throws before calling next() can be unparked (see releaseOrphanedResponse()); every path
636
+ * that resolves a command this way goes through here for that reason.
637
+ *
638
+ * @param request - Pending request entry
639
+ * @param parsed - Parsed tagged response
640
+ * @param hasTrailingData - Whether more input was already buffered after this line
641
+ * @internal
642
+ */
643
+ resolveParked(request, parsed, hasTrailingData) {
644
+ return new Promise(resolve => {
645
+ let release = () => {
646
+ if (this.parkedRelease === release) {
647
+ this.parkedRelease = null;
648
+ }
649
+ resolve();
650
+ };
651
+ this.parkedRelease = release;
652
+ request.resolve({ response: parsed, next: release, hasTrailingData });
653
+ });
616
654
  }
617
655
  /**
618
656
  * Waits out a throttle back-off.
@@ -646,6 +684,10 @@ export class ImapFlow extends EventEmitter {
646
684
  while ((data = this.streamer.read()) !== null) {
647
685
  let keepReading;
648
686
  try {
687
+ // The reader is parked here for as long as processing takes (a fetch consumer
688
+ // working on a row), so a socket that goes quiet meanwhile is this side's doing,
689
+ // see _socketTimeout
690
+ this._processingResponse = true;
649
691
  keepReading = await this.handleResponse(data);
650
692
  }
651
693
  catch (err) {
@@ -662,6 +704,7 @@ export class ImapFlow extends EventEmitter {
662
704
  this.failProtocol(error);
663
705
  }
664
706
  finally {
707
+ this._processingResponse = false;
665
708
  this.releaseStreamData(data);
666
709
  }
667
710
  if (!keepReading) {
@@ -753,15 +796,14 @@ export class ImapFlow extends EventEmitter {
753
796
  this.log.warn({ err, cid: this.id });
754
797
  }
755
798
  }
756
- let logCompiled = await compiler(parsed, {
757
- isLogging: true
758
- });
759
- if (/^\d+$/.test(parsed.command || '') && parsed.attributes && parsed.attributes[0] && parsed.attributes[0].value === 'FETCH') {
760
- // too many FETCH responses, might want to filter these out
761
- this.log.trace({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
762
- }
763
- else {
764
- this.log.debug({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
799
+ // FETCH responses are the bulk of a session, so they log at trace. The serialization is
800
+ // skipped when the level is off.
801
+ let logLevel = /^\d+$/.test(parsed.command || '') && parsed.attributes && parsed.attributes[0] && parsed.attributes[0].value === 'FETCH' ? 'trace' : 'debug';
802
+ if (this.isLogLevelEnabled(logLevel)) {
803
+ let logCompiled = await compiler(parsed, {
804
+ isLogging: true
805
+ });
806
+ this.log[logLevel]({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
765
807
  }
766
808
  // IMAP "+" (continuation request) handling. The server sends "+" in two cases:
767
809
  // 1. During IDLE or AUTHENTICATE, where a custom handler (onPlusTag) processes it
@@ -837,9 +879,7 @@ export class ImapFlow extends EventEmitter {
837
879
  // and an entirely unknown tag is recorded but tolerated.
838
880
  if (parsed.tag && !['*', '+'].includes(parsed.tag)) {
839
881
  if (this.currentRequest && this.currentRequest.tag === parsed.tag && this.currentRequest.sent) {
840
- let request = this.requestTagMap.get(parsed.tag);
841
- this.requestTagMap.delete(parsed.tag);
842
- this.currentRequest = false;
882
+ let request = this.takeCurrentRequest();
843
883
  if (request) {
844
884
  await this.settleRequest(request, parsed, !!data.trailingAfterLine);
845
885
  }
@@ -899,7 +939,7 @@ export class ImapFlow extends EventEmitter {
899
939
  case 'BYE':
900
940
  // hasTrailingData is forwarded so STARTTLS can detect a plaintext
901
941
  // injection (data buffered after the tagged OK, before the handshake).
902
- await new Promise(resolve => request.resolve({ response: parsed, next: resolve, hasTrailingData }));
942
+ await this.resolveParked(request, parsed, hasTrailingData);
903
943
  break;
904
944
  case 'NO':
905
945
  case 'BAD': {
@@ -927,7 +967,7 @@ export class ImapFlow extends EventEmitter {
927
967
  // is told nothing else about it, so this entry is the only record that
928
968
  // the response was truncated.
929
969
  this.log.warn({ msg: 'Partial FETCH response', cid: this.id, err });
930
- await new Promise(resolve => request.resolve({ response: parsed, next: resolve }));
970
+ await this.resolveParked(request, parsed);
931
971
  break;
932
972
  }
933
973
  let throttleDelay = false;
@@ -1021,6 +1061,16 @@ export class ImapFlow extends EventEmitter {
1021
1061
  if (typeof socket.setKeepAlive === 'function') {
1022
1062
  socket.setKeepAlive(true, 5 * 1000);
1023
1063
  }
1064
+ this.armSocketTimeout(socket);
1065
+ }
1066
+ /**
1067
+ * Arms the inactivity watchdog on a socket. Guarded the same way as the rest of
1068
+ * configureSocket(): other runtimes stub socket features.
1069
+ *
1070
+ * @param socket - The socket to arm
1071
+ * @internal
1072
+ */
1073
+ armSocketTimeout(socket) {
1024
1074
  if (typeof socket.setTimeout === 'function') {
1025
1075
  socket.setTimeout(this.socketTimeout);
1026
1076
  }
@@ -1060,25 +1110,50 @@ export class ImapFlow extends EventEmitter {
1060
1110
  this._socketTimeout =
1061
1111
  this._socketTimeout ||
1062
1112
  (() => {
1063
- const err = new Error('Socket timeout');
1064
- err.code = 'ETIMEOUT';
1065
- const quietExpected = this.idling || this._openDownloads || this.currentLock;
1113
+ const timeoutError = () => {
1114
+ const err = new Error('Socket timeout');
1115
+ err.code = 'ETIMEOUT';
1116
+ return err;
1117
+ };
1118
+ if (!this.usable || !this.socket || this.socket.destroyed) {
1119
+ this.emitError(timeoutError());
1120
+ return;
1121
+ }
1122
+ if (this._processingResponse) {
1123
+ // The reader is parked processing a response (a fetch consumer taking its
1124
+ // time over a row), so nothing is being read: the quiet is this side's, and
1125
+ // the server is not overdue with anything. The timer is one-shot and nothing
1126
+ // resumes it until reading does, so it is re-armed for the next check.
1127
+ this.log.debug({ msg: 'Socket timeout while a response is being processed', cid: this.id });
1128
+ this.armSocketTimeout(this.socket);
1129
+ return;
1130
+ }
1131
+ // A throttle back-off is quiet by design, the NOOP below probes that the server
1132
+ // is still there
1133
+ const quietExpected = this.idling || this._openDownloads || this.currentLock || this._throttleWaits.size;
1066
1134
  const commandStuck = this.currentRequest && !(this.idling && this.currentRequest.command === 'IDLE');
1067
- if (quietExpected && !commandStuck) {
1068
- if (!this.usable || !this.socket || this.socket.destroyed) {
1069
- this.emitError(err);
1070
- return;
1071
- }
1072
- this.run('NOOP').catch(err => {
1135
+ // A recovery NOOP that has not settled by the next timeout means the peer is gone
1136
+ if (quietExpected && !commandStuck && !this._recoveryPending) {
1137
+ // The inactivity timer is one-shot, re-armed only by traffic. The NOOP can not
1138
+ // reach the wire while IDLE still waits for its continuation (DONE is sent
1139
+ // only after the "+"), so without re-arming it here a peer that went silent
1140
+ // right after IDLE would never time out again.
1141
+ this._recoveryPending = true;
1142
+ this.armSocketTimeout(this.socket);
1143
+ this.run('NOOP')
1144
+ .catch(err => {
1073
1145
  this.log.warn({ msg: 'Connection recovery failed after timeout', err, cid: this.id });
1074
1146
  if (!this.isClosed) {
1075
1147
  this.close();
1076
1148
  }
1149
+ })
1150
+ .finally(() => {
1151
+ this._recoveryPending = false;
1077
1152
  });
1078
1153
  }
1079
1154
  else {
1080
1155
  this.log.debug({ msg: 'Socket timeout', cid: this.id });
1081
- this.emitError(err);
1156
+ this.emitError(timeoutError());
1082
1157
  }
1083
1158
  });
1084
1159
  const socket = this.socket;
@@ -1308,8 +1383,15 @@ export class ImapFlow extends EventEmitter {
1308
1383
  err.tlsFailed = true;
1309
1384
  throw err;
1310
1385
  }
1311
- // Opportunistic STARTTLS. But it's not possible right now.
1312
- // Attention: Could be a downgrade attack.
1386
+ // Opportunistic STARTTLS not offered. The capability list arrived in cleartext, so this
1387
+ // may be a downgrade attack: say so rather than fall back silently (see the doSTARTTLS
1388
+ // option for the policy).
1389
+ this.log.warn({
1390
+ msg: 'Server does not support STARTTLS, continuing over an unencrypted connection',
1391
+ host: this.host,
1392
+ port: this.port,
1393
+ cid: this.id
1394
+ });
1313
1395
  return false;
1314
1396
  }
1315
1397
  /**
@@ -1572,7 +1654,7 @@ export class ImapFlow extends EventEmitter {
1572
1654
  throw new AuthenticationFailure('No matching authentication method');
1573
1655
  }
1574
1656
  /** @internal */
1575
- beginSession(onUnhandledError) {
1657
+ beginSession() {
1576
1658
  clearTimer(this.greetingTimeout);
1577
1659
  this.greetingReceived = true;
1578
1660
  this.untaggedHandlers.OK = null;
@@ -1591,15 +1673,21 @@ export class ImapFlow extends EventEmitter {
1591
1673
  }
1592
1674
  })
1593
1675
  .catch(err => {
1594
- this.log.error({ err, cid: this.id });
1676
+ // The transport goes with the failed attempt: the instance can not be reused, and
1677
+ // an open socket would only trip the inactivity watchdog later
1678
+ this.closeAfter();
1595
1679
  if (typeof this.initialReject === 'function') {
1680
+ this.log.error({ err, cid: this.id });
1596
1681
  clearTimer(this.greetingTimeout);
1597
1682
  let reject = this.initialReject;
1598
1683
  this.initialResolve = false;
1599
1684
  this.initialReject = false;
1600
1685
  return reject(err);
1601
1686
  }
1602
- onUnhandledError(err);
1687
+ // connect() was already settled by whatever took the connection down (a socket
1688
+ // error or close rejected it first), so this failure is a consequence of that.
1689
+ // A second 'error' event for it would throw when no listener is attached.
1690
+ logConnectionError(this, 'Session setup failed', err);
1603
1691
  });
1604
1692
  }
1605
1693
  /** @internal */
@@ -1607,8 +1695,7 @@ export class ImapFlow extends EventEmitter {
1607
1695
  this.greeting = getTextValues(message.attributes)
1608
1696
  .filter(entry => entry)
1609
1697
  .join('');
1610
- // ALWAYS emit the error so users can handle it
1611
- this.beginSession(err => this.emitError(err));
1698
+ this.beginSession();
1612
1699
  }
1613
1700
  /** @internal */
1614
1701
  async initialPREAUTH() {
@@ -1619,10 +1706,7 @@ export class ImapFlow extends EventEmitter {
1619
1706
  // documented contract for the `authenticated` property: `true` when the
1620
1707
  // connection was authenticated by a PREAUTH greeting (no credentials known)
1621
1708
  this.authenticated = true;
1622
- this.beginSession(err => {
1623
- this.log.error({ err, cid: this.id });
1624
- this.closeAfter();
1625
- });
1709
+ this.beginSession();
1626
1710
  }
1627
1711
  /** @internal */
1628
1712
  async serverBye(parsed) {
@@ -1634,10 +1718,13 @@ export class ImapFlow extends EventEmitter {
1634
1718
  this.byeReason = reason || 'Server closed connection';
1635
1719
  this.untaggedHandlers.BYE = null;
1636
1720
  this.state = this.states.LOGOUT;
1637
- // A BYE greeting rejects the connection outright. Do not wait for the server to close
1638
- // the socket: one that keeps it open would leave connect() pending until the greeting
1639
- // timeout.
1640
- if (!this.greetingReceived) {
1721
+ // BYE means the server is about to close the connection (RFC 9051 7.1.5), so the socket
1722
+ // is not waited out: a BYE greeting kept open would leave connect() pending until the
1723
+ // greeting timeout, and an unsolicited BYE whose FIN never arrives would leave the
1724
+ // in-flight command and the queue behind it waiting for the socket watchdog. The BYE
1725
+ // that answers a LOGOUT is the exception: its tagged completion closes the connection.
1726
+ const answersLogout = !!this.currentRequest && this.currentRequest.command.toUpperCase() === 'LOGOUT';
1727
+ if (!this.greetingReceived || !answersLogout) {
1641
1728
  this.closeAfter();
1642
1729
  }
1643
1730
  }
@@ -1699,7 +1786,7 @@ export class ImapFlow extends EventEmitter {
1699
1786
  // keep exists up to date
1700
1787
  let prevCount = this.mailbox.exists;
1701
1788
  this.mailbox.exists = count;
1702
- this.emit('exists', {
1789
+ emitSafe(this, 'exists', {
1703
1790
  path: this.mailbox.path,
1704
1791
  count,
1705
1792
  prevCount
@@ -1710,7 +1797,7 @@ export class ImapFlow extends EventEmitter {
1710
1797
  /** @internal */
1711
1798
  async notifyExpunge(payload) {
1712
1799
  if (typeof this.options.expungeHandler !== 'function') {
1713
- this.emit('expunge', payload);
1800
+ emitSafe(this, 'expunge', payload);
1714
1801
  return;
1715
1802
  }
1716
1803
  try {
@@ -1797,7 +1884,7 @@ export class ImapFlow extends EventEmitter {
1797
1884
  if (message.flagColor) {
1798
1885
  updateEvent.flagColor = message.flagColor;
1799
1886
  }
1800
- this.emit('flags', updateEvent);
1887
+ emitSafe(this, 'flags', updateEvent);
1801
1888
  }
1802
1889
  }
1803
1890
  /** @internal */
@@ -1981,6 +2068,12 @@ export class ImapFlow extends EventEmitter {
1981
2068
  reject(err);
1982
2069
  }, deadline.remaining());
1983
2070
  let onConnect = () => {
2071
+ // close() may have run between the socket assignment and this callback (the
2072
+ // cleartext proxy path defers it): connect() is already rejected and the socket
2073
+ // gone, so arming the greeting timer now would only fire a stray GREETING_TIMEOUT
2074
+ if (this.isClosed || !this.socket) {
2075
+ return;
2076
+ }
1984
2077
  try {
1985
2078
  clearTimer(this.connectTimeout);
1986
2079
  // ImapFlow now owns the socket; drop the proxy's early error handler
@@ -2115,9 +2208,13 @@ export class ImapFlow extends EventEmitter {
2115
2208
  this.idling = false;
2116
2209
  this.closeConnectSteps();
2117
2210
  if (typeof this.preCheck === 'function') {
2118
- // Runs while the connection is being torn down, so the rejection this sees is
2119
- // almost always the NoConnection close() is about to raise itself.
2120
- this.preCheck().catch(err => logConnectionError(this, 'Failed to break IDLE while closing', err));
2211
+ // Taken off the connection first: breaking IDLE writes DONE, a write on a dead
2212
+ // transport calls close(), and close() must not find this function here and
2213
+ // re-enter it. The rejection this sees is almost always the NoConnection close()
2214
+ // is about to raise itself.
2215
+ let preCheck = this.preCheck;
2216
+ this.preCheck = false;
2217
+ preCheck().catch(err => logConnectionError(this, 'Failed to break IDLE while closing', err));
2121
2218
  }
2122
2219
  // Session-only public state must not survive the connection it describes: callers read
2123
2220
  // these properties in reconnect logic and would otherwise mistake cached objects for
@@ -2237,6 +2334,7 @@ export class ImapFlow extends EventEmitter {
2237
2334
  closeRequests() {
2238
2335
  // Collect all pending requests to reject
2239
2336
  let pendingRequests = [];
2337
+ this.commandParts = [];
2240
2338
  // reject command that is currently processed
2241
2339
  if (this.currentRequest && this.requestTagMap.has(this.currentRequest.tag)) {
2242
2340
  let tag = this.currentRequest.tag;
@@ -3095,7 +3193,34 @@ export class ImapFlow extends EventEmitter {
3095
3193
  throw this.createNoConnectionError(false, { rejectedFrom: 'noSocket', command });
3096
3194
  }
3097
3195
  let handler = this.commands.get(command);
3098
- return await handler(this, ...args);
3196
+ try {
3197
+ return await handler(this, ...args);
3198
+ }
3199
+ finally {
3200
+ this.releaseOrphanedResponse(command);
3201
+ }
3202
+ }
3203
+ /**
3204
+ * Safety net for the `next()` contract of settleRequest(): a command handler that throws or
3205
+ * returns between the resolution of its exec() and the `next()` call would leave the reader
3206
+ * loop parked, stalling every later command until the socket timeout. Checked one macrotask
3207
+ * after the handler settled, so a concurrently running handler whose response has just been
3208
+ * resolved gets to release it itself first.
3209
+ *
3210
+ * @param command Command name, for the log entry.
3211
+ * @internal
3212
+ */
3213
+ releaseOrphanedResponse(command) {
3214
+ let release = this.parkedRelease;
3215
+ if (!release) {
3216
+ return;
3217
+ }
3218
+ setImmediate(() => {
3219
+ if (this.parkedRelease === release) {
3220
+ this.log.warn({ msg: 'Command handler did not release its response', command, cid: this.id });
3221
+ release();
3222
+ }
3223
+ });
3099
3224
  }
3100
3225
  // Mailbox lock queue processor. Implements a mutex pattern: only one lock
3101
3226
  // is active at a time. When the active lock is released, the next queued
@@ -3231,6 +3356,11 @@ export class ImapFlow extends EventEmitter {
3231
3356
  try {
3232
3357
  // Need to SELECT/EXAMINE a different mailbox
3233
3358
  await this.mailboxOpen(path, options);
3359
+ if (!this.mailbox) {
3360
+ // mailboxOpen() resolves with nothing when the connection is no longer
3361
+ // authenticated (a BYE arrived meanwhile): no mailbox, so no lock on it
3362
+ throw this.createNoConnectionError(false, { rejectedFrom: 'mailboxLock', path });
3363
+ }
3234
3364
  this.log.trace({
3235
3365
  msg: 'Mailbox lock acquired [selected]',
3236
3366
  path,
@@ -3341,35 +3471,36 @@ export class ImapFlow extends EventEmitter {
3341
3471
  // {logger:false} never consults mainLogger, so it does not create the default logger
3342
3472
  mainLogger = createConnectionLogger({ cid: this.id, logRaw: this.options.logRaw });
3343
3473
  }
3474
+ // Whether the configured logger takes entries at this level: a disabled logger takes
3475
+ // none, a plain object of level methods the levels it has methods for, plus error and
3476
+ // fatal, which fall back to the console. The level methods and isLevelEnabled() below
3477
+ // share this one rule.
3478
+ const reachesLogger = (level) => this.options.logger !== false && (typeof mainLogger[level] === 'function' || level === 'error' || level === 'fatal');
3344
3479
  let synteticLogger = {};
3345
3480
  let levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
3346
3481
  for (let level of levels) {
3347
3482
  synteticLogger[level] = (...args) => {
3348
- // using {logger:false} disables logging
3349
- if (this.options.logger !== false) {
3483
+ if (reachesLogger(level)) {
3350
3484
  const logMethod = mainLogger[level];
3351
- if (typeof logMethod !== 'function') {
3352
- // we are checking to make sure the level is supported.
3353
- // if it isn't supported but the level is error or fatal, log to console anyway.
3354
- if (level === 'fatal' || level === 'error') {
3355
- let entry = args[0];
3356
- try {
3357
- if (entry && typeof entry === 'object' && entry.err) {
3358
- entry = Object.assign({}, entry, { err: flattenLoggedError(entry.err) });
3359
- }
3360
- console.error(JSON.stringify(entry));
3361
- }
3362
- catch {
3363
- // Serializing failed (a circular structure, a BigInt, a throwing
3364
- // getter). This fallback exists so an error is never lost, so hand
3365
- // the entry to console.error itself - it inspects rather than
3366
- // serializes, and handles all three - instead of dropping it.
3367
- console.error(entry);
3368
- }
3369
- }
3485
+ if (typeof logMethod === 'function') {
3486
+ logMethod.apply(mainLogger, args);
3370
3487
  }
3371
3488
  else {
3372
- logMethod.apply(mainLogger, args);
3489
+ // error or fatal without a method of its own: the console, so it is never lost
3490
+ let entry = args[0];
3491
+ try {
3492
+ if (entry && typeof entry === 'object' && entry.err) {
3493
+ entry = Object.assign({}, entry, { err: flattenLoggedError(entry.err) });
3494
+ }
3495
+ console.error(JSON.stringify(entry));
3496
+ }
3497
+ catch {
3498
+ // Serializing failed (a circular structure, a BigInt, a throwing
3499
+ // getter). This fallback exists so an error is never lost, so hand
3500
+ // the entry to console.error itself - it inspects rather than
3501
+ // serializes, and handles all three - instead of dropping it.
3502
+ console.error(entry);
3503
+ }
3373
3504
  }
3374
3505
  }
3375
3506
  if (this.emitLogs && args && args[0] && typeof args[0] === 'object') {
@@ -3392,8 +3523,23 @@ export class ImapFlow extends EventEmitter {
3392
3523
  }
3393
3524
  };
3394
3525
  }
3526
+ // Whether an entry at this level reaches anyone, so the caller can skip building an
3527
+ // expensive one (a response serialized for the log) that nobody would see. 'log' events
3528
+ // carry every entry whatever the logger does with it, and a pino-like logger knows its
3529
+ // own threshold.
3530
+ synteticLogger.isLevelEnabled = (level) => this.emitLogs || (typeof mainLogger.isLevelEnabled === 'function' ? !!mainLogger.isLevelEnabled(level) : reachesLogger(level));
3395
3531
  return synteticLogger;
3396
3532
  }
3533
+ /**
3534
+ * Whether a log entry at this level reaches anyone, so the caller can skip building an
3535
+ * expensive one. A logger assigned to `log` from outside may lack the method, and then
3536
+ * every level counts as enabled.
3537
+ *
3538
+ * @internal
3539
+ */
3540
+ isLogLevelEnabled(level) {
3541
+ return typeof this.log.isLevelEnabled === 'function' ? this.log.isLevelEnabled(level) : true;
3542
+ }
3397
3543
  /**
3398
3544
  * Detaches sockets from the IMAP pipeline. Useful for upgrading the connection
3399
3545
  * (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.6";
2
+ export declare const version = "2.2.7";
3
3
  export declare const homepage = "https://imapflow.com/";
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/build.js from package.json. Do not edit by hand.
2
2
  export const name = "imapflow";
3
- export const version = "2.2.6";
3
+ export const version = "2.2.7";
4
4
  export const homepage = "https://imapflow.com/";
@@ -234,11 +234,11 @@ const httpConnect = async ({ logger, proxyUrl, secureProxy, proxyHost, proxyPort
234
234
  * @returns An IPv4 address.
235
235
  */
236
236
  const resolveIPv4 = async (hostname, deadline) => {
237
- let addresses = await deadline.race(dns.promises.resolve4(hostname));
238
- if (!addresses || !addresses.length) {
239
- throw proxyError(`Could not resolve an IPv4 address for ${hostname}`, 'EPROXY');
240
- }
241
- return addresses[0];
237
+ // lookup() goes through the system resolver (getaddrinfo), so hosts files and mDNS names
238
+ // resolve the same way they would for a direct connection. It rejects (ENOTFOUND) rather
239
+ // than returning nothing when the name has no IPv4 address
240
+ let { address } = await deadline.race(dns.promises.lookup(hostname, { family: 4 }));
241
+ return address;
242
242
  };
243
243
  /**
244
244
  * Establishes a tunnel through a SOCKS proxy.
@@ -282,8 +282,8 @@ const socksConnect = async ({ logger, proxyUrl, protocol, proxyHost, proxyPort,
282
282
  set_tcp_nodelay: true
283
283
  };
284
284
  if (proxyUrl.username || proxyUrl.password) {
285
- connectionOpts.proxy.userId = proxyUrl.username;
286
- connectionOpts.proxy.password = proxyUrl.password;
285
+ connectionOpts.proxy.userId = decodeUserInfo(proxyUrl.username);
286
+ connectionOpts.proxy.password = decodeUserInfo(proxyUrl.password);
287
287
  }
288
288
  // The dependency treats a zero timeout as its own 30 second default, so only a strictly
289
289
  // positive remaining budget may be passed.