imapflow 2.2.6 → 2.2.8

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 (76) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cjs/commands/append.js +10 -3
  3. package/dist/cjs/commands/authenticate.js +42 -22
  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/enable.js +6 -0
  8. package/dist/cjs/commands/esearch-parser.js +8 -2
  9. package/dist/cjs/commands/fetch.js +57 -14
  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 +8 -8
  22. package/dist/cjs/download.js +220 -95
  23. package/dist/cjs/handler/imap-compiler.js +19 -10
  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 +36 -28
  29. package/dist/cjs/imap-flow.d.ts +2 -2
  30. package/dist/cjs/imap-flow.js +256 -95
  31. package/dist/cjs/package-info.d.ts +1 -1
  32. package/dist/cjs/package-info.js +1 -1
  33. package/dist/cjs/proxy-connection.js +7 -7
  34. package/dist/cjs/search-compiler.js +33 -13
  35. package/dist/cjs/special-use.js +10 -5
  36. package/dist/cjs/tools.d.ts +14 -4
  37. package/dist/cjs/tools.js +69 -9
  38. package/dist/cjs/types.d.ts +19 -4
  39. package/dist/esm/commands/append.js +11 -4
  40. package/dist/esm/commands/authenticate.js +43 -23
  41. package/dist/esm/commands/close.d.ts +12 -1
  42. package/dist/esm/commands/close.js +5 -3
  43. package/dist/esm/commands/delete.js +2 -1
  44. package/dist/esm/commands/enable.js +6 -0
  45. package/dist/esm/commands/esearch-parser.js +8 -2
  46. package/dist/esm/commands/fetch.js +57 -14
  47. package/dist/esm/commands/id.js +9 -2
  48. package/dist/esm/commands/idle.js +16 -6
  49. package/dist/esm/commands/list.js +10 -1
  50. package/dist/esm/commands/login.js +6 -2
  51. package/dist/esm/commands/logout.js +7 -0
  52. package/dist/esm/commands/namespace.js +7 -3
  53. package/dist/esm/commands/quota.js +3 -1
  54. package/dist/esm/commands/rename.js +2 -1
  55. package/dist/esm/commands/select.js +5 -0
  56. package/dist/esm/commands/status.js +7 -2
  57. package/dist/esm/commands/store.d.ts +1 -1
  58. package/dist/esm/commands/store.js +9 -9
  59. package/dist/esm/download.js +220 -95
  60. package/dist/esm/handler/imap-compiler.js +19 -10
  61. package/dist/esm/handler/limits.d.ts +11 -0
  62. package/dist/esm/handler/limits.js +14 -0
  63. package/dist/esm/handler/parser-instance.d.ts +10 -0
  64. package/dist/esm/handler/parser-instance.js +25 -10
  65. package/dist/esm/handler/token-parser.js +37 -29
  66. package/dist/esm/imap-flow.d.ts +2 -2
  67. package/dist/esm/imap-flow.js +256 -95
  68. package/dist/esm/package-info.d.ts +1 -1
  69. package/dist/esm/package-info.js +1 -1
  70. package/dist/esm/proxy-connection.js +7 -7
  71. package/dist/esm/search-compiler.js +33 -13
  72. package/dist/esm/special-use.js +10 -5
  73. package/dist/esm/tools.d.ts +14 -4
  74. package/dist/esm/tools.js +68 -9
  75. package/dist/esm/types.d.ts +19 -4
  76. package/package.json +5 -3
@@ -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
  /**
@@ -1377,9 +1459,8 @@ export class ImapFlow extends EventEmitter {
1377
1459
  // of the IP - accepting any "localhost" certificate for any IP-hosted
1378
1460
  // server, and rejecting legitimate IP-SAN certificates.
1379
1461
  host: this.host,
1380
- servername: this.servername,
1381
1462
  port: this.port
1382
- }, this.options.tls || {});
1463
+ }, this.tlsServername(), this.options.tls || {});
1383
1464
  this.clearSocketHandlers();
1384
1465
  let settled = false;
1385
1466
  // Single settlement path for the upgrade. Every terminal outcome - handshake
@@ -1572,7 +1653,7 @@ export class ImapFlow extends EventEmitter {
1572
1653
  throw new AuthenticationFailure('No matching authentication method');
1573
1654
  }
1574
1655
  /** @internal */
1575
- beginSession(onUnhandledError) {
1656
+ beginSession() {
1576
1657
  clearTimer(this.greetingTimeout);
1577
1658
  this.greetingReceived = true;
1578
1659
  this.untaggedHandlers.OK = null;
@@ -1591,15 +1672,21 @@ export class ImapFlow extends EventEmitter {
1591
1672
  }
1592
1673
  })
1593
1674
  .catch(err => {
1594
- this.log.error({ err, cid: this.id });
1675
+ // The transport goes with the failed attempt: the instance can not be reused, and
1676
+ // an open socket would only trip the inactivity watchdog later
1677
+ this.closeAfter();
1595
1678
  if (typeof this.initialReject === 'function') {
1679
+ this.log.error({ err, cid: this.id });
1596
1680
  clearTimer(this.greetingTimeout);
1597
1681
  let reject = this.initialReject;
1598
1682
  this.initialResolve = false;
1599
1683
  this.initialReject = false;
1600
1684
  return reject(err);
1601
1685
  }
1602
- onUnhandledError(err);
1686
+ // connect() was already settled by whatever took the connection down (a socket
1687
+ // error or close rejected it first), so this failure is a consequence of that.
1688
+ // A second 'error' event for it would throw when no listener is attached.
1689
+ logConnectionError(this, 'Session setup failed', err);
1603
1690
  });
1604
1691
  }
1605
1692
  /** @internal */
@@ -1607,8 +1694,7 @@ export class ImapFlow extends EventEmitter {
1607
1694
  this.greeting = getTextValues(message.attributes)
1608
1695
  .filter(entry => entry)
1609
1696
  .join('');
1610
- // ALWAYS emit the error so users can handle it
1611
- this.beginSession(err => this.emitError(err));
1697
+ this.beginSession();
1612
1698
  }
1613
1699
  /** @internal */
1614
1700
  async initialPREAUTH() {
@@ -1619,10 +1705,7 @@ export class ImapFlow extends EventEmitter {
1619
1705
  // documented contract for the `authenticated` property: `true` when the
1620
1706
  // connection was authenticated by a PREAUTH greeting (no credentials known)
1621
1707
  this.authenticated = true;
1622
- this.beginSession(err => {
1623
- this.log.error({ err, cid: this.id });
1624
- this.closeAfter();
1625
- });
1708
+ this.beginSession();
1626
1709
  }
1627
1710
  /** @internal */
1628
1711
  async serverBye(parsed) {
@@ -1634,10 +1717,13 @@ export class ImapFlow extends EventEmitter {
1634
1717
  this.byeReason = reason || 'Server closed connection';
1635
1718
  this.untaggedHandlers.BYE = null;
1636
1719
  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) {
1720
+ // BYE means the server is about to close the connection (RFC 9051 7.1.5), so the socket
1721
+ // is not waited out: a BYE greeting kept open would leave connect() pending until the
1722
+ // greeting timeout, and an unsolicited BYE whose FIN never arrives would leave the
1723
+ // in-flight command and the queue behind it waiting for the socket watchdog. The BYE
1724
+ // that answers a LOGOUT is the exception: its tagged completion closes the connection.
1725
+ const answersLogout = !!this.currentRequest && this.currentRequest.command.toUpperCase() === 'LOGOUT';
1726
+ if (!this.greetingReceived || !answersLogout) {
1641
1727
  this.closeAfter();
1642
1728
  }
1643
1729
  }
@@ -1699,7 +1785,7 @@ export class ImapFlow extends EventEmitter {
1699
1785
  // keep exists up to date
1700
1786
  let prevCount = this.mailbox.exists;
1701
1787
  this.mailbox.exists = count;
1702
- this.emit('exists', {
1788
+ emitSafe(this, 'exists', {
1703
1789
  path: this.mailbox.path,
1704
1790
  count,
1705
1791
  prevCount
@@ -1710,7 +1796,7 @@ export class ImapFlow extends EventEmitter {
1710
1796
  /** @internal */
1711
1797
  async notifyExpunge(payload) {
1712
1798
  if (typeof this.options.expungeHandler !== 'function') {
1713
- this.emit('expunge', payload);
1799
+ emitSafe(this, 'expunge', payload);
1714
1800
  return;
1715
1801
  }
1716
1802
  try {
@@ -1764,12 +1850,18 @@ export class ImapFlow extends EventEmitter {
1764
1850
  uids = untagged.attributes[0].value;
1765
1851
  }
1766
1852
  let uidList = expandRange(uids);
1853
+ let earlier = tags.includes('EARLIER');
1854
+ // RFC 7162 section 3.2.10: unlike VANISHED (EARLIER), a plain VANISHED reports messages the
1855
+ // client knows about and decrements the message count like the same number of EXPUNGEs would
1856
+ if (!earlier) {
1857
+ mailbox.exists = Math.max(0, mailbox.exists - uidList.length);
1858
+ }
1767
1859
  for (let uid of uidList) {
1768
1860
  let payload = {
1769
1861
  path: mailbox.path,
1770
1862
  uid,
1771
1863
  vanished: true,
1772
- earlier: tags.includes('EARLIER')
1864
+ earlier
1773
1865
  };
1774
1866
  await this.notifyExpunge(payload);
1775
1867
  }
@@ -1781,7 +1873,7 @@ export class ImapFlow extends EventEmitter {
1781
1873
  // mailbox closed, ignore
1782
1874
  return;
1783
1875
  }
1784
- let message = await formatMessageResponse(untagged, mailbox, this.idHashAlgorithm);
1876
+ let message = await formatMessageResponse(untagged, mailbox, this.idHashAlgorithm, this);
1785
1877
  if (message.flags) {
1786
1878
  let updateEvent = {
1787
1879
  path: mailbox.path,
@@ -1797,7 +1889,7 @@ export class ImapFlow extends EventEmitter {
1797
1889
  if (message.flagColor) {
1798
1890
  updateEvent.flagColor = message.flagColor;
1799
1891
  }
1800
- this.emit('flags', updateEvent);
1892
+ emitSafe(this, 'flags', updateEvent);
1801
1893
  }
1802
1894
  }
1803
1895
  /** @internal */
@@ -1810,6 +1902,12 @@ export class ImapFlow extends EventEmitter {
1810
1902
  }
1811
1903
  return true;
1812
1904
  }
1905
+ // servername for tls.connect(), left out for an IP literal host (this.servername is false then):
1906
+ // Node treats a false value like a missing one, but Bun throws a TypeError for it
1907
+ /** @internal */
1908
+ tlsServername() {
1909
+ return this.servername ? { servername: this.servername } : {};
1910
+ }
1813
1911
  // Normalizes a message range from various input formats into an IMAP-compatible
1814
1912
  // sequence string (e.g., "1:5,7,10:*"). Handles: numbers, "*", {all:true},
1815
1913
  // {uid:value}, search query objects (resolved via SEARCH), and arrays of numbers.
@@ -1852,6 +1950,11 @@ export class ImapFlow extends EventEmitter {
1852
1950
  if (!value) {
1853
1951
  return false;
1854
1952
  }
1953
+ // An empty mailbox has no message numbers: every sequence set, "1:*" included, would get a
1954
+ // BAD (RFC 9051 section 9, seq-number). UID sets may point past the end, so they are sent.
1955
+ if (!options.uid && this.mailbox && !this.mailbox.exists) {
1956
+ return false;
1957
+ }
1855
1958
  return value;
1856
1959
  }
1857
1960
  // The single definition of "the connection is not free". A held or queued mailbox lock, a
@@ -1921,9 +2024,8 @@ export class ImapFlow extends EventEmitter {
1921
2024
  let connector = this.secureConnection ? tls : net;
1922
2025
  let opts = Object.assign({
1923
2026
  host: this.host,
1924
- servername: this.servername,
1925
2027
  port: this.port
1926
- }, this.options.tls || {});
2028
+ }, this.tlsServername(), this.options.tls || {});
1927
2029
  this.untaggedHandlers.OK = (...args) => this.initialOK(...args);
1928
2030
  this.untaggedHandlers.BYE = (...args) => this.serverBye(...args);
1929
2031
  this.untaggedHandlers.PREAUTH = () => this.initialPREAUTH();
@@ -1981,6 +2083,12 @@ export class ImapFlow extends EventEmitter {
1981
2083
  reject(err);
1982
2084
  }, deadline.remaining());
1983
2085
  let onConnect = () => {
2086
+ // close() may have run between the socket assignment and this callback (the
2087
+ // cleartext proxy path defers it): connect() is already rejected and the socket
2088
+ // gone, so arming the greeting timer now would only fire a stray GREETING_TIMEOUT
2089
+ if (this.isClosed || !this.socket) {
2090
+ return;
2091
+ }
1984
2092
  try {
1985
2093
  clearTimer(this.connectTimeout);
1986
2094
  // ImapFlow now owns the socket; drop the proxy's early error handler
@@ -2115,9 +2223,13 @@ export class ImapFlow extends EventEmitter {
2115
2223
  this.idling = false;
2116
2224
  this.closeConnectSteps();
2117
2225
  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));
2226
+ // Taken off the connection first: breaking IDLE writes DONE, a write on a dead
2227
+ // transport calls close(), and close() must not find this function here and
2228
+ // re-enter it. The rejection this sees is almost always the NoConnection close()
2229
+ // is about to raise itself.
2230
+ let preCheck = this.preCheck;
2231
+ this.preCheck = false;
2232
+ preCheck().catch(err => logConnectionError(this, 'Failed to break IDLE while closing', err));
2121
2233
  }
2122
2234
  // Session-only public state must not survive the connection it describes: callers read
2123
2235
  // these properties in reconnect logic and would otherwise mistake cached objects for
@@ -2237,6 +2349,7 @@ export class ImapFlow extends EventEmitter {
2237
2349
  closeRequests() {
2238
2350
  // Collect all pending requests to reject
2239
2351
  let pendingRequests = [];
2352
+ this.commandParts = [];
2240
2353
  // reject command that is currently processed
2241
2354
  if (this.currentRequest && this.requestTagMap.has(this.currentRequest.tag)) {
2242
2355
  let tag = this.currentRequest.tag;
@@ -3095,7 +3208,34 @@ export class ImapFlow extends EventEmitter {
3095
3208
  throw this.createNoConnectionError(false, { rejectedFrom: 'noSocket', command });
3096
3209
  }
3097
3210
  let handler = this.commands.get(command);
3098
- return await handler(this, ...args);
3211
+ try {
3212
+ return await handler(this, ...args);
3213
+ }
3214
+ finally {
3215
+ this.releaseOrphanedResponse(command);
3216
+ }
3217
+ }
3218
+ /**
3219
+ * Safety net for the `next()` contract of settleRequest(): a command handler that throws or
3220
+ * returns between the resolution of its exec() and the `next()` call would leave the reader
3221
+ * loop parked, stalling every later command until the socket timeout. Checked one macrotask
3222
+ * after the handler settled, so a concurrently running handler whose response has just been
3223
+ * resolved gets to release it itself first.
3224
+ *
3225
+ * @param command Command name, for the log entry.
3226
+ * @internal
3227
+ */
3228
+ releaseOrphanedResponse(command) {
3229
+ let release = this.parkedRelease;
3230
+ if (!release) {
3231
+ return;
3232
+ }
3233
+ setImmediate(() => {
3234
+ if (this.parkedRelease === release) {
3235
+ this.log.warn({ msg: 'Command handler did not release its response', command, cid: this.id });
3236
+ release();
3237
+ }
3238
+ });
3099
3239
  }
3100
3240
  // Mailbox lock queue processor. Implements a mutex pattern: only one lock
3101
3241
  // is active at a time. When the active lock is released, the next queued
@@ -3231,6 +3371,11 @@ export class ImapFlow extends EventEmitter {
3231
3371
  try {
3232
3372
  // Need to SELECT/EXAMINE a different mailbox
3233
3373
  await this.mailboxOpen(path, options);
3374
+ if (!this.mailbox) {
3375
+ // mailboxOpen() resolves with nothing when the connection is no longer
3376
+ // authenticated (a BYE arrived meanwhile): no mailbox, so no lock on it
3377
+ throw this.createNoConnectionError(false, { rejectedFrom: 'mailboxLock', path });
3378
+ }
3234
3379
  this.log.trace({
3235
3380
  msg: 'Mailbox lock acquired [selected]',
3236
3381
  path,
@@ -3341,35 +3486,36 @@ export class ImapFlow extends EventEmitter {
3341
3486
  // {logger:false} never consults mainLogger, so it does not create the default logger
3342
3487
  mainLogger = createConnectionLogger({ cid: this.id, logRaw: this.options.logRaw });
3343
3488
  }
3489
+ // Whether the configured logger takes entries at this level: a disabled logger takes
3490
+ // none, a plain object of level methods the levels it has methods for, plus error and
3491
+ // fatal, which fall back to the console. The level methods and isLevelEnabled() below
3492
+ // share this one rule.
3493
+ const reachesLogger = (level) => this.options.logger !== false && (typeof mainLogger[level] === 'function' || level === 'error' || level === 'fatal');
3344
3494
  let synteticLogger = {};
3345
3495
  let levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
3346
3496
  for (let level of levels) {
3347
3497
  synteticLogger[level] = (...args) => {
3348
- // using {logger:false} disables logging
3349
- if (this.options.logger !== false) {
3498
+ if (reachesLogger(level)) {
3350
3499
  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
- }
3500
+ if (typeof logMethod === 'function') {
3501
+ logMethod.apply(mainLogger, args);
3370
3502
  }
3371
3503
  else {
3372
- logMethod.apply(mainLogger, args);
3504
+ // error or fatal without a method of its own: the console, so it is never lost
3505
+ let entry = args[0];
3506
+ try {
3507
+ if (entry && typeof entry === 'object' && entry.err) {
3508
+ entry = Object.assign({}, entry, { err: flattenLoggedError(entry.err) });
3509
+ }
3510
+ console.error(JSON.stringify(entry));
3511
+ }
3512
+ catch {
3513
+ // Serializing failed (a circular structure, a BigInt, a throwing
3514
+ // getter). This fallback exists so an error is never lost, so hand
3515
+ // the entry to console.error itself - it inspects rather than
3516
+ // serializes, and handles all three - instead of dropping it.
3517
+ console.error(entry);
3518
+ }
3373
3519
  }
3374
3520
  }
3375
3521
  if (this.emitLogs && args && args[0] && typeof args[0] === 'object') {
@@ -3392,8 +3538,23 @@ export class ImapFlow extends EventEmitter {
3392
3538
  }
3393
3539
  };
3394
3540
  }
3541
+ // Whether an entry at this level reaches anyone, so the caller can skip building an
3542
+ // expensive one (a response serialized for the log) that nobody would see. 'log' events
3543
+ // carry every entry whatever the logger does with it, and a pino-like logger knows its
3544
+ // own threshold.
3545
+ synteticLogger.isLevelEnabled = (level) => this.emitLogs || (typeof mainLogger.isLevelEnabled === 'function' ? !!mainLogger.isLevelEnabled(level) : reachesLogger(level));
3395
3546
  return synteticLogger;
3396
3547
  }
3548
+ /**
3549
+ * Whether a log entry at this level reaches anyone, so the caller can skip building an
3550
+ * expensive one. A logger assigned to `log` from outside may lack the method, and then
3551
+ * every level counts as enabled.
3552
+ *
3553
+ * @internal
3554
+ */
3555
+ isLogLevelEnabled(level) {
3556
+ return typeof this.log.isLevelEnabled === 'function' ? this.log.isLevelEnabled(level) : true;
3557
+ }
3397
3558
  /**
3398
3559
  * Detaches sockets from the IMAP pipeline. Useful for upgrading the connection
3399
3560
  * (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.8";
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.8";
4
4
  export const homepage = "https://imapflow.com/";