imapflow 1.3.5 → 1.3.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 (44) hide show
  1. package/.github/codeql/codeql-config.yml +12 -0
  2. package/.github/workflows/codeql.yml +102 -0
  3. package/.github/workflows/stale.yml +5 -0
  4. package/.github/workflows/test.yml +3 -0
  5. package/.release-please-manifest.json +1 -1
  6. package/CHANGELOG.md +14 -0
  7. package/lib/commands/authenticate.js +5 -5
  8. package/lib/commands/fetch.js +1 -0
  9. package/lib/commands/logout.js +1 -0
  10. package/lib/commands/select.js +2 -1
  11. package/lib/commands/starttls.js +3 -0
  12. package/lib/commands/store.js +1 -0
  13. package/lib/handler/imap-stream.js +23 -6
  14. package/lib/handler/token-parser.js +0 -5
  15. package/lib/imap-flow.d.ts +5 -0
  16. package/lib/imap-flow.js +205 -29
  17. package/lib/proxy-connection.js +26 -1
  18. package/lib/tools.js +12 -0
  19. package/package.json +1 -1
  20. package/test/commands-branches-test.js +1068 -0
  21. package/test/connection-edge-cases-test.js +138 -1
  22. package/test/fixtures/test-tls.js +8 -0
  23. package/test/handler-branches-test.js +334 -0
  24. package/test/imap-compiler-test.js +47 -0
  25. package/test/imap-flow-compress-test.js +154 -0
  26. package/test/imap-flow-coverage-test.js +609 -0
  27. package/test/imap-flow-fetch-download-test.js +801 -0
  28. package/test/imap-flow-internals-test.js +450 -0
  29. package/test/imap-flow-methods-test.js +738 -0
  30. package/test/imap-flow-proxy-paths-test.js +215 -0
  31. package/test/imap-flow-secure-test.js +327 -0
  32. package/test/imap-flow-server-test.js +1046 -0
  33. package/test/imap-formal-syntax-test.js +18 -0
  34. package/test/imap-parser-test.js +52 -0
  35. package/test/imap-stream-edge-cases-test.js +122 -0
  36. package/test/jp-decoder-test.js +48 -0
  37. package/test/limited-passthrough-test.js +17 -0
  38. package/test/proxy-connection-test.js +86 -0
  39. package/test/reliability-improvements-test.js +88 -0
  40. package/test/search-compiler-test.js +31 -0
  41. package/test/search-test.js +61 -0
  42. package/test/starttls-injection-test.js +181 -0
  43. package/test/token-parser-test.js +64 -0
  44. package/test/tools-test.js +360 -0
package/lib/imap-flow.js CHANGED
@@ -23,7 +23,7 @@ const libbase64 = require('libbase64');
23
23
  const FlowedDecoder = require('@zone-eu/mailsplit/lib/flowed-decoder');
24
24
  const { PassThrough } = require('stream');
25
25
 
26
- const { proxyConnection } = require('./proxy-connection');
26
+ const { proxyConnection, detachEarlyErrorHandler } = require('./proxy-connection');
27
27
 
28
28
  const {
29
29
  comparePaths,
@@ -292,13 +292,24 @@ class ImapFlow extends EventEmitter {
292
292
  cid: this.id,
293
293
  logRaw: this.logRaw,
294
294
  secureConnection: this.secureConnection,
295
- maxLineLength: this.options.maxLineLength
295
+ maxLineLength: this.options.maxLineLength,
296
+ maxLiteralSize: this.options.maxLiteralSize
296
297
  });
297
298
 
298
299
  this.reading = false;
299
300
  this.socket = false;
300
301
  this.writeSocket = false;
301
302
 
303
+ // Tracked throttle back-off timer (see reader()). Stored so close() can clear it
304
+ // and abort the wait instead of letting it keep the event loop alive for minutes.
305
+ this._throttleTimer = null;
306
+ this._throttleAbort = null;
307
+
308
+ // Pending rejector of the in-flight STARTTLS upgrade promise (see upgradeToSTARTTLS()).
309
+ // Stored so emitError() can route a streamer-originated error into the upgrade's single
310
+ // error path instead of dropping it (which could hang a verifyOnly connect()).
311
+ this._upgradeReject = null;
312
+
302
313
  this.isClosed = false;
303
314
 
304
315
  this.states = states;
@@ -327,6 +338,10 @@ class ImapFlow extends EventEmitter {
327
338
 
328
339
  this.expectCapabilityUpdate = false; // force CAPABILITY after LOGIN
329
340
 
341
+ // Set true if the server sent data after the STARTTLS OK and before the TLS
342
+ // handshake (a plaintext-injection signal). See upgradeToSTARTTLS().
343
+ this._starttlsHadTrailingData = false;
344
+
330
345
  /**
331
346
  * Enabled capabilities. Usually `CONDSTORE` and `UTF8=ACCEPT` if server supports these.
332
347
  * @type {Set<string>}
@@ -405,6 +420,34 @@ class ImapFlow extends EventEmitter {
405
420
  }
406
421
  err._connId = err._connId || this.id;
407
422
 
423
+ // During a STARTTLS handshake the upgrade promise owns the single error path
424
+ // (tlsSocketErrorHandler -> reject). Route the error there so a streamer-originated
425
+ // failure is surfaced with its real code (instead of a generic ClosedAfterConnect*)
426
+ // and cannot hang a verifyOnly connect() waiting on a 'close' that never rejects.
427
+ // Fall back to closing if the upgrade has no pending rejector.
428
+ if (this.upgrading) {
429
+ this.upgrading = false;
430
+ this.closeAfter();
431
+ if (typeof this._upgradeReject === 'function') {
432
+ let reject = this._upgradeReject;
433
+ this._upgradeReject = null;
434
+ reject(err);
435
+ }
436
+ return;
437
+ }
438
+
439
+ // While the initial connect promise is still pending it owns error reporting:
440
+ // reject it once instead of emitting a duplicate 'error' event (which would also
441
+ // throw if the caller has not attached an 'error' listener yet).
442
+ if (typeof this.initialReject === 'function') {
443
+ let reject = this.initialReject;
444
+ this.initialResolve = false;
445
+ this.initialReject = false;
446
+ this.closeAfter();
447
+ reject(err);
448
+ return;
449
+ }
450
+
408
451
  this.closeAfter();
409
452
  this.emit('error', err);
410
453
  }
@@ -533,6 +576,7 @@ class ImapFlow extends EventEmitter {
533
576
  isLogging: true
534
577
  });
535
578
 
579
+ /* c8 ignore next */ // send() is always invoked with a request object carrying options, so the {} fallback is unreachable
536
580
  let options = data.options || {};
537
581
 
538
582
  this.log.debug({ src: 'c', msg: logCompiled.toString(), cid: this.id, comment: options.comment });
@@ -689,8 +733,14 @@ class ImapFlow extends EventEmitter {
689
733
  // Server acknowledged our literal size with "+", send the actual literal data
690
734
  if (parsed.tag === '+' && this.commandParts.length) {
691
735
  let content = this.commandParts.shift();
692
- this.write(content);
693
- this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
736
+ // A write() failure here (e.g. socket closed mid-command) must not propagate
737
+ // out of the loop and skip data.next(), which would stall the parser stream.
738
+ try {
739
+ this.write(content);
740
+ this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
741
+ } catch (err) {
742
+ this.log.warn({ err, cid: this.id });
743
+ }
694
744
  data.next();
695
745
  continue;
696
746
  }
@@ -725,15 +775,22 @@ class ImapFlow extends EventEmitter {
725
775
  this.requestTagMap.delete(parsed.tag);
726
776
 
727
777
  if (this.currentRequest && this.currentRequest.tag === parsed.tag) {
728
- // send next pending command
778
+ // send next pending command. A failure here must not propagate out of the
779
+ // loop and skip data.next() below, which would stall the parser stream.
729
780
  this.currentRequest = false;
730
- await this.trySend();
781
+ try {
782
+ await this.trySend();
783
+ } catch (err) {
784
+ this.log.warn({ err, cid: this.id });
785
+ }
731
786
  }
732
787
 
733
788
  switch (parsed.command.toUpperCase()) {
734
789
  case 'OK':
735
790
  case 'BYE':
736
- await new Promise(resolve => request.resolve({ response: parsed, next: resolve }));
791
+ // hasTrailingData is forwarded so STARTTLS can detect a plaintext
792
+ // injection (data buffered after the tagged OK, before the handshake).
793
+ await new Promise(resolve => request.resolve({ response: parsed, next: resolve, hasTrailingData: !!data.trailingAfterLine }));
737
794
  break;
738
795
 
739
796
  case 'NO':
@@ -796,7 +853,29 @@ class ImapFlow extends EventEmitter {
796
853
  }
797
854
 
798
855
  this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err });
799
- await new Promise(r => setTimeout(r, delayResponse));
856
+
857
+ // Tracked, abortable wait. Storing the timer lets close() clear it so
858
+ // the back-off never keeps the event loop alive, and storing the resolve
859
+ // lets close() abort the wait promptly (aborted=true) instead of blocking
860
+ // the reader for up to 5 minutes and rejecting long after the connection
861
+ // is gone. Normal expiry resolves with aborted=false.
862
+ let aborted = await new Promise(resolve => {
863
+ this._throttleAbort = resolve;
864
+ this._throttleTimer = setTimeout(() => resolve(false), delayResponse);
865
+ if (typeof this._throttleTimer.unref === 'function') {
866
+ this._throttleTimer.unref();
867
+ }
868
+ });
869
+ this._throttleTimer = null;
870
+ this._throttleAbort = null;
871
+
872
+ if (aborted) {
873
+ // Connection closed during back-off: reject promptly with a
874
+ // connection error (carrying any server BYE reason) instead of
875
+ // waiting out the throttle delay.
876
+ request.reject(this.createNoConnectionError(this.byeReason));
877
+ break;
878
+ }
800
879
  }
801
880
  }
802
881
 
@@ -847,12 +926,6 @@ class ImapFlow extends EventEmitter {
847
926
  // Clear any existing handlers first to prevent duplicates
848
927
  this.clearSocketHandlers();
849
928
 
850
- // Remove temporary connection error handler if present
851
- if (this._connectErrorHandler && this.socket) {
852
- this.socket.removeListener('error', this._connectErrorHandler);
853
- this._connectErrorHandler = null;
854
- }
855
-
856
929
  this._socketError =
857
930
  this._socketError ||
858
931
  (err => {
@@ -1013,7 +1086,13 @@ class ImapFlow extends EventEmitter {
1013
1086
  this.streamer.compress = true;
1014
1087
  this.socket.pipe(this._inflate).pipe(this.streamer);
1015
1088
  this._inflate.on('error', err => {
1016
- this.streamer.emit('error', err);
1089
+ // Only forward into the streamer while it is alive and still has an error
1090
+ // listener. After close() the streamer is destroyed and its listener removed,
1091
+ // so emitting 'error' would throw an unhandled error and crash the process.
1092
+ // (this.streamer is assigned once in the constructor and never nulled.)
1093
+ if (!this.streamer.destroyed && this.streamer.listenerCount('error')) {
1094
+ this.streamer.emit('error', err);
1095
+ }
1017
1096
  });
1018
1097
 
1019
1098
  // For outgoing data, replace the writeSocket with a PassThrough buffer.
@@ -1024,6 +1103,7 @@ class ImapFlow extends EventEmitter {
1024
1103
  highWaterMark: 64 * 1024 // 64KB buffer limit to prevent excessive memory usage
1025
1104
  });
1026
1105
 
1106
+ /* c8 ignore start */ // destroySoon override is never invoked by ImapFlow (close() calls destroy()); kept for stream API completeness
1027
1107
  this.writeSocket.destroySoon = () => {
1028
1108
  try {
1029
1109
  if (this.socket) {
@@ -1035,6 +1115,7 @@ class ImapFlow extends EventEmitter {
1035
1115
  throw err;
1036
1116
  }
1037
1117
  };
1118
+ /* c8 ignore stop */
1038
1119
 
1039
1120
  Object.defineProperty(this.writeSocket, 'destroyed', {
1040
1121
  get: () => !this.socket || this.socket.destroyed
@@ -1058,6 +1139,7 @@ class ImapFlow extends EventEmitter {
1058
1139
 
1059
1140
  // Yield to event loop every 100 chunks to prevent CPU blocking
1060
1141
  processedChunks++;
1142
+ /* c8 ignore next 6 */ // requires 100+ queued chunks in a single pump pass; not reproducible deterministically
1061
1143
  if (processedChunks % 100 === 0) {
1062
1144
  await new Promise(resolve => setImmediate(resolve));
1063
1145
  if (!this.writeSocket) {
@@ -1072,6 +1154,7 @@ class ImapFlow extends EventEmitter {
1072
1154
  }
1073
1155
 
1074
1156
  reading = false;
1157
+ /* c8 ignore next 3 */ // defensive: the pump body does not throw under normal operation
1075
1158
  } catch (ex) {
1076
1159
  this.emitError(ex);
1077
1160
  }
@@ -1139,11 +1222,42 @@ class ImapFlow extends EventEmitter {
1139
1222
  return this._failSTARTTLS();
1140
1223
  }
1141
1224
 
1225
+ // STARTTLS plaintext-injection guard (RFC 3501 §6.2.1): a compliant server stays
1226
+ // silent after the tagged STARTTLS OK until the TLS handshake, so any data that
1227
+ // followed the OK was injected by a MITM and must not be treated as if it arrived
1228
+ // over TLS. Two complementary best-effort checks fail closed before wrapping the
1229
+ // socket; injection that still races in afterwards corrupts the TLS handshake and
1230
+ // is rejected there instead (with a generic TLS error rather than STARTTLS_INJECTION).
1231
+ const failSTARTTLSInjection = () => {
1232
+ let err = new Error('Server sent data after the STARTTLS response and before the TLS handshake; possible plaintext-injection attack');
1233
+ err.code = 'STARTTLS_INJECTION';
1234
+ err.tlsFailed = true;
1235
+ this.closeAfter();
1236
+ return err;
1237
+ };
1238
+
1239
+ // Check 1: the parser saw more input already buffered right after the tagged OK
1240
+ // (same TCP segment, or an already-queued chunk) — see hasTrailingData / starttls.js.
1241
+ if (this._starttlsHadTrailingData) {
1242
+ throw failSTARTTLSInjection();
1243
+ }
1244
+
1142
1245
  // STARTTLS upgrade sequence: detach the plain socket from the parser,
1143
1246
  // wrap it in a TLS socket, then reconnect the new TLS socket to the
1144
1247
  // parser. The plain socket becomes the underlying transport for TLS.
1145
1248
  this.socket.unpipe(this.streamer);
1249
+
1250
+ // Check 2: now that the parser is detached, any bytes still buffered on the plain
1251
+ // socket arrived after the OK and were not consumed by the handshake — i.e. injected.
1252
+ // This catches late/fragmented injection that the parse-time snapshot cannot see.
1253
+ let injectedTail = typeof this.socket.read === 'function' ? this.socket.read() : null;
1254
+ /* c8 ignore next 3 */ // late/fragmented post-OK injection is timing-dependent and not deterministically reproducible
1255
+ if (injectedTail && injectedTail.length) {
1256
+ throw failSTARTTLSInjection();
1257
+ }
1146
1258
  let upgraded = await new Promise((resolve, reject) => {
1259
+ // Expose this rejector so emitError() can settle the upgrade with a streamer error.
1260
+ this._upgradeReject = reject;
1147
1261
  let socketPlain = this.socket;
1148
1262
  let opts = Object.assign(
1149
1263
  {
@@ -1156,6 +1270,7 @@ class ImapFlow extends EventEmitter {
1156
1270
  this.clearSocketHandlers();
1157
1271
 
1158
1272
  // Store error handler for cleanup after successful upgrade
1273
+ /* c8 ignore start */ // plain-socket error during the TLS handshake window is timing-dependent (errors surface via the streamer/emitError path in tests)
1159
1274
  const socketPlainErrorHandler = err => {
1160
1275
  clearTimeout(this.connectTimeout);
1161
1276
  clearTimeout(this.upgradeTimeout);
@@ -1168,8 +1283,10 @@ class ImapFlow extends EventEmitter {
1168
1283
  err.tlsFailed = true;
1169
1284
  reject(err);
1170
1285
  };
1286
+ /* c8 ignore stop */
1171
1287
  socketPlain.once('error', socketPlainErrorHandler);
1172
1288
 
1289
+ /* c8 ignore start */ // UPGRADE_TIMEOUT is 10s; firing it deterministically would make the test suite hang
1173
1290
  this.upgradeTimeout = setTimeout(() => {
1174
1291
  if (!this.upgrading) {
1175
1292
  return;
@@ -1180,6 +1297,7 @@ class ImapFlow extends EventEmitter {
1180
1297
  err.code = 'UPGRADE_TIMEOUT';
1181
1298
  reject(err);
1182
1299
  }, UPGRADE_TIMEOUT);
1300
+ /* c8 ignore stop */
1183
1301
 
1184
1302
  // A TLS handshake failure (bad certificate, protocol mismatch, etc.) is emitted on the
1185
1303
  // new TLS socket, not on the plain socket, so it must be handled here. Without this the
@@ -1188,10 +1306,12 @@ class ImapFlow extends EventEmitter {
1188
1306
  const tlsSocketErrorHandler = err => {
1189
1307
  clearTimeout(this.connectTimeout);
1190
1308
  clearTimeout(this.upgradeTimeout);
1309
+ /* c8 ignore start */ // the already-settled early return is a defensive double-fire guard, not separately exercised
1191
1310
  if (!this.upgrading) {
1192
1311
  // already settled
1193
1312
  return;
1194
1313
  }
1314
+ /* c8 ignore stop */
1195
1315
  this.upgrading = false;
1196
1316
  err.tlsFailed = true;
1197
1317
  this.clearSocketHandlers();
@@ -1203,10 +1323,12 @@ class ImapFlow extends EventEmitter {
1203
1323
  this.socket = tls.connect(opts, () => {
1204
1324
  try {
1205
1325
  clearTimeout(this.upgradeTimeout);
1326
+ /* c8 ignore start */ // race: connection closed during the TLS handshake window
1206
1327
  if (this.isClosed) {
1207
1328
  // not sure if this is possible?
1208
1329
  return this.close();
1209
1330
  }
1331
+ /* c8 ignore stop */
1210
1332
 
1211
1333
  // TLS handshake complete. Reconnect the now-encrypted socket
1212
1334
  // to the IMAP parser stream and record the cipher details.
@@ -1214,6 +1336,7 @@ class ImapFlow extends EventEmitter {
1214
1336
  this.upgrading = false;
1215
1337
  this.streamer.secureConnection = true;
1216
1338
  this.socket.pipe(this.streamer);
1339
+ /* c8 ignore next */ // an upgraded TLS socket always exposes getCipher(), so the false fallback is unreachable
1217
1340
  this.tls = typeof this.socket.getCipher === 'function' ? this.socket.getCipher() : false;
1218
1341
  if (this.tls) {
1219
1342
  this.tls.authorized = this.socket.authorized;
@@ -1222,6 +1345,7 @@ class ImapFlow extends EventEmitter {
1222
1345
  msg: 'Established TLS session',
1223
1346
  cid: this.id,
1224
1347
  authorized: this.tls.authorized,
1348
+ /* c8 ignore next */ // cipher.standardName is present on modern Node, so the .name fallback rarely runs
1225
1349
  algo: this.tls.standardName || this.tls.name,
1226
1350
  version: this.tls.version
1227
1351
  });
@@ -1231,20 +1355,30 @@ class ImapFlow extends EventEmitter {
1231
1355
  socketPlain.removeListener('error', socketPlainErrorHandler);
1232
1356
  this.socket.removeListener('error', tlsSocketErrorHandler);
1233
1357
 
1358
+ // Install the normal socket handlers only now that the handshake
1359
+ // succeeded. Doing this during the handshake would leave both
1360
+ // tlsSocketErrorHandler and the generic _socketError on the socket;
1361
+ // a handshake 'error' would then fire BOTH (EventEmitter clones its
1362
+ // listener array on emit), causing a duplicate error and a possible
1363
+ // unhandled 'error' crash. Keeping tlsSocketErrorHandler as the sole
1364
+ // listener until here guarantees a single error path for the upgrade.
1365
+ this.setSocketHandlers();
1366
+
1367
+ this._upgradeReject = null;
1234
1368
  return resolve(true);
1369
+ /* c8 ignore next 3 */ // defensive: the success callback body does not throw under normal operation
1235
1370
  } catch (ex) {
1236
1371
  this.emitError(ex);
1237
1372
  }
1238
1373
  });
1239
1374
 
1240
- // Registered after tls.connect (the TLS socket now exists) but before
1241
- // setSocketHandlers() so it fires first on a handshake error and removes the generic
1242
- // handlers, keeping this the single error path for the upgrade.
1375
+ // Registered after tls.connect (the TLS socket now exists). This is the ONLY
1376
+ // error listener during the handshake window; the generic handlers are installed
1377
+ // by setSocketHandlers() inside the success callback above, so a handshake error
1378
+ // has a single error path (tlsSocketErrorHandler -> reject).
1243
1379
  this.socket.once('error', tlsSocketErrorHandler);
1244
1380
 
1245
1381
  this.writeSocket = this.socket;
1246
-
1247
- this.setSocketHandlers();
1248
1382
  });
1249
1383
 
1250
1384
  if (upgraded && this.expectCapabilityUpdate) {
@@ -1682,6 +1816,7 @@ class ImapFlow extends EventEmitter {
1682
1816
  let err = new Error('Failed to establish connection in required time');
1683
1817
  err.code = 'CONNECT_TIMEOUT';
1684
1818
  err.details = {
1819
+ /* c8 ignore next */ // firing the timeout with the default (large) value would hang the suite, so only the explicit-option path is tested
1685
1820
  connectionTimeout: this.options.connectionTimeout || CONNECT_TIMEOUT
1686
1821
  };
1687
1822
  this.log.error({ err, cid: this.id });
@@ -1692,15 +1827,22 @@ class ImapFlow extends EventEmitter {
1692
1827
  let onConnect = () => {
1693
1828
  try {
1694
1829
  clearTimeout(this.connectTimeout);
1830
+
1831
+ // ImapFlow now owns the socket; drop the proxy's early error handler
1832
+ // (its "before connection setup" message no longer applies).
1833
+ detachEarlyErrorHandler(socket);
1834
+
1695
1835
  this.socket.setKeepAlive(true, 5 * 1000);
1696
1836
  this.socket.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT);
1697
1837
 
1698
1838
  this.greetingTimeout = setTimeout(() => {
1699
1839
  let err = new Error(
1840
+ /* c8 ignore next */ // the greeting-timeout test uses a plaintext socket; the secure-socket branch of this hint is not separately exercised
1700
1841
  `Failed to receive greeting from server in required time${!this.secureConnection ? '. Maybe should use TLS?' : ''}`
1701
1842
  );
1702
1843
  err.code = 'GREETING_TIMEOUT';
1703
1844
  err.details = {
1845
+ /* c8 ignore next */ // firing the timeout with the default (large) value would hang the suite, so only the explicit-option path is tested
1704
1846
  greetingTimeout: this.options.greetingTimeout || GREETING_TIMEOUT
1705
1847
  };
1706
1848
  this.log.error({ err, cid: this.id });
@@ -1725,6 +1867,7 @@ class ImapFlow extends EventEmitter {
1725
1867
 
1726
1868
  if (this.tls) {
1727
1869
  logInfo.authorized = this.tls.authorized = this.socket.authorized;
1870
+ /* c8 ignore next */ // cipher.standardName is present on modern Node, so the .name fallback rarely runs
1728
1871
  logInfo.algo = this.tls.standardName || this.tls.name;
1729
1872
  logInfo.version = this.tls.version;
1730
1873
  }
@@ -1738,6 +1881,7 @@ class ImapFlow extends EventEmitter {
1738
1881
  // executed by initial "* OK"
1739
1882
  this.initialResolve = resolve;
1740
1883
  this.initialReject = reject;
1884
+ /* c8 ignore next 4 */ // defensive: the onConnect setup body does not throw under normal operation
1741
1885
  } catch (ex) {
1742
1886
  // connect failed
1743
1887
  reject(ex);
@@ -1802,6 +1946,17 @@ class ImapFlow extends EventEmitter {
1802
1946
  setImmediate(() => this.close());
1803
1947
  }
1804
1948
 
1949
+ // Builds the standard "connection not available" error, optionally annotated with the
1950
+ // server's BYE reason. Single source of truth so every NoConnection rejection is consistent.
1951
+ createNoConnectionError(byeReason) {
1952
+ const error = new Error('Connection not available');
1953
+ error.code = 'NoConnection';
1954
+ if (byeReason) {
1955
+ error.reason = byeReason;
1956
+ }
1957
+ return error;
1958
+ }
1959
+
1805
1960
  /**
1806
1961
  * Closes TCP connection without notifying the server.
1807
1962
  *
@@ -1819,6 +1974,15 @@ class ImapFlow extends EventEmitter {
1819
1974
  clearTimeout(this.connectTimeout);
1820
1975
  clearTimeout(this.greetingTimeout);
1821
1976
 
1977
+ // Abort any in-flight throttle back-off so the reader unblocks and the
1978
+ // throttled request is rejected promptly rather than after the full delay.
1979
+ clearTimeout(this._throttleTimer);
1980
+ this._throttleTimer = null;
1981
+ if (typeof this._throttleAbort === 'function') {
1982
+ this._throttleAbort(true);
1983
+ this._throttleAbort = null;
1984
+ }
1985
+
1822
1986
  this.usable = false;
1823
1987
  this.idling = false;
1824
1988
 
@@ -1828,7 +1992,13 @@ class ImapFlow extends EventEmitter {
1828
1992
  this.initialResolve = false;
1829
1993
  this.initialReject = false;
1830
1994
  let err = new Error('Unexpected close');
1995
+ /* c8 ignore next */ // closing a pending connect over an already-secure socket (the TLS branch) is not separately exercised
1831
1996
  err.code = `ClosedAfterConnect${this.secureConnection ? 'TLS' : 'Text'}`;
1997
+ // Surface the server's BYE reason (e.g. "Too many connections") when the
1998
+ // connection was closed by an untagged BYE, so the caller sees why.
1999
+ if (this.byeReason) {
2000
+ err.reason = this.byeReason;
2001
+ }
1832
2002
  // Synchronous rejection is safe: connectPromise.catch(noop) is already
1833
2003
  // attached, so the rejection is observed immediately. close() is synchronous,
1834
2004
  // so all cleanup completes before any microtask rejection handler runs.
@@ -1865,15 +2035,8 @@ class ImapFlow extends EventEmitter {
1865
2035
  }
1866
2036
  }
1867
2037
 
1868
- // Helper to create connection error
1869
- const createNoConnectionError = byeReason => {
1870
- const error = new Error('Connection not available');
1871
- error.code = 'NoConnection';
1872
- if (byeReason) {
1873
- error.reason = byeReason;
1874
- }
1875
- return error;
1876
- };
2038
+ // Helper to create connection error (delegates to the shared builder)
2039
+ const createNoConnectionError = byeReason => this.createNoConnectionError(byeReason);
1877
2040
 
1878
2041
  // Reject pending requests and locks synchronously. Each exec() and
1879
2042
  // getMailboxLock() promise already has .catch(noop) attached, so the
@@ -3168,6 +3331,7 @@ class ImapFlow extends EventEmitter {
3168
3331
  }
3169
3332
 
3170
3333
  if (disposition.value) {
3334
+ /* c8 ignore next */ // a parsed disposition value is never all-whitespace, so the `false` fallback is unreachable
3171
3335
  meta.disposition = disposition.value.toLowerCase().trim() || false;
3172
3336
  try {
3173
3337
  meta.disposition = libmime.decodeWords(meta.disposition);
@@ -3298,6 +3462,7 @@ class ImapFlow extends EventEmitter {
3298
3462
  let resolved = false;
3299
3463
 
3300
3464
  const finish = err => {
3465
+ /* c8 ignore next */ // the first call removes all three listeners, so a later drain/error/close can't re-enter finish; this guard is belt-and-suspenders
3301
3466
  if (resolved) return;
3302
3467
  resolved = true;
3303
3468
 
@@ -3306,6 +3471,7 @@ class ImapFlow extends EventEmitter {
3306
3471
  stream.removeAllListeners('error');
3307
3472
  stream.removeAllListeners('close');
3308
3473
 
3474
+ /* c8 ignore next 2 */ // stream error during a backpressure drain wait is timing-dependent
3309
3475
  if (err) {
3310
3476
  reject(err);
3311
3477
  } else {
@@ -3317,12 +3483,14 @@ class ImapFlow extends EventEmitter {
3317
3483
  stream.once('error', err => finish(err));
3318
3484
  stream.once('close', () => finish());
3319
3485
  });
3486
+ /* c8 ignore start */ // re-throw path only triggers on a stream error mid-drain, which is timing-dependent
3320
3487
  } catch (err) {
3321
3488
  // Re-throw only if not aborted
3322
3489
  if (!fetchAborted) {
3323
3490
  throw err;
3324
3491
  }
3325
3492
  }
3493
+ /* c8 ignore stop */
3326
3494
 
3327
3495
  // Check if we should abort after waiting
3328
3496
  if (fetchAborted) {
@@ -3342,6 +3510,7 @@ class ImapFlow extends EventEmitter {
3342
3510
  .catch(err => {
3343
3511
  if (!fetchAborted && stream && !stream.destroyed) {
3344
3512
  stream.emit('error', err);
3513
+ /* c8 ignore start */ // the else logs when a fetch error arrives after the stream was already torn down (timing-dependent)
3345
3514
  } else {
3346
3515
  // Log when error cannot be emitted to stream
3347
3516
  this.log.warn({
@@ -3352,6 +3521,7 @@ class ImapFlow extends EventEmitter {
3352
3521
  cid: this.id
3353
3522
  });
3354
3523
  }
3524
+ /* c8 ignore stop */
3355
3525
  })
3356
3526
  .finally(() => {
3357
3527
  if (!fetchAborted && stream && !stream.destroyed) {
@@ -3366,12 +3536,14 @@ class ImapFlow extends EventEmitter {
3366
3536
  writeResult = writeChunk(chunk);
3367
3537
  } catch (err) {
3368
3538
  stream.emit('error', err);
3539
+ /* c8 ignore next 3 */ // emitting the error above triggers cleanup (fetchAborted=true), so this end() guard is already false here
3369
3540
  if (!fetchAborted && stream && !stream.destroyed) {
3370
3541
  stream.end();
3371
3542
  }
3372
3543
  return;
3373
3544
  }
3374
3545
 
3546
+ /* c8 ignore next 7 */ // `stream` is piped to the limiter before this runs, so the head write drains synchronously and always returns true (verified for chunkSize up to 8MB); the drain-wait branch is unreachable
3375
3547
  if (!writeResult) {
3376
3548
  // Initial chunk filled the buffer, wait for drain
3377
3549
  stream.once('drain', () => {
@@ -3476,6 +3648,7 @@ class ImapFlow extends EventEmitter {
3476
3648
  }
3477
3649
 
3478
3650
  if (disposition.value) {
3651
+ /* c8 ignore next */ // a parsed disposition value is never all-whitespace, so the `false` fallback is unreachable
3479
3652
  data[key].meta.disposition = disposition.value.toLowerCase().trim() || false;
3480
3653
  try {
3481
3654
  data[key].meta.disposition = libmime.decodeWords(data[key].meta.disposition);
@@ -3615,6 +3788,7 @@ class ImapFlow extends EventEmitter {
3615
3788
  lockId: lock.lockId,
3616
3789
  path,
3617
3790
  heldFor: Date.now() - lock.heldAt,
3791
+ /* c8 ignore next */ // the held-lock-warning diagnostic with a description set is a timing-dependent log detail
3618
3792
  ...(options.description && { description: options.description }),
3619
3793
  cid: this.id
3620
3794
  });
@@ -3722,11 +3896,13 @@ class ImapFlow extends EventEmitter {
3722
3896
  // New locks may have been queued while we were processing (e.g.,
3723
3897
  // a lock that failed immediately and the next getMailboxLock call
3724
3898
  // arrived before we finished). Schedule another run if needed.
3899
+ /* c8 ignore start */ // requires a lock to be enqueued during an in-flight processLocks pass; not reproducible deterministically
3725
3900
  if (this.locks.length && !this.currentLock) {
3726
3901
  setImmediate(() => {
3727
3902
  this.processLocks().catch(err => this.log.error({ err, cid: this.id }));
3728
3903
  });
3729
3904
  }
3905
+ /* c8 ignore stop */
3730
3906
  }
3731
3907
  }
3732
3908
 
@@ -15,6 +15,29 @@ const hidePassword = proxyUrl => {
15
15
  }
16
16
  };
17
17
 
18
+ // Attaches a benign 'error' listener as soon as the proxied socket exists, so an early
19
+ // socket error (before ImapFlow installs its own handlers) cannot surface as an unhandled
20
+ // 'error' event and crash the process. The handler is stored on the socket so the caller
21
+ // can remove it once it takes ownership of the socket.
22
+ const attachEarlyErrorHandler = (logger, socket) => {
23
+ if (!socket || typeof socket.on !== 'function') {
24
+ return;
25
+ }
26
+ socket._earlyErrorHandler = err => {
27
+ logger.error({ msg: 'Proxy socket error before connection setup', err });
28
+ };
29
+ socket.on('error', socket._earlyErrorHandler);
30
+ };
31
+
32
+ // Removes the handler installed by attachEarlyErrorHandler once the caller takes ownership
33
+ // of the socket. Keeps the internal `_earlyErrorHandler` contract inside this module.
34
+ const detachEarlyErrorHandler = socket => {
35
+ if (socket && socket._earlyErrorHandler) {
36
+ socket.removeListener('error', socket._earlyErrorHandler);
37
+ socket._earlyErrorHandler = null;
38
+ }
39
+ };
40
+
18
41
  const proxyConnection = async (logger, connectionUrl, host, port) => {
19
42
  let proxyUrl = new URL(connectionUrl);
20
43
 
@@ -44,6 +67,7 @@ const proxyConnection = async (logger, connectionUrl, host, port) => {
44
67
  port,
45
68
  host
46
69
  });
70
+ attachEarlyErrorHandler(logger, socket);
47
71
  }
48
72
  return socket;
49
73
  } catch (err) {
@@ -106,6 +130,7 @@ const proxyConnection = async (logger, connectionUrl, host, port) => {
106
130
  port,
107
131
  host
108
132
  });
133
+ attachEarlyErrorHandler(logger, info.socket);
109
134
  }
110
135
  return info.socket;
111
136
  } catch (err) {
@@ -123,4 +148,4 @@ const proxyConnection = async (logger, connectionUrl, host, port) => {
123
148
  }
124
149
  };
125
150
 
126
- module.exports = { proxyConnection };
151
+ module.exports = { proxyConnection, detachEarlyErrorHandler };