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.
- package/.github/codeql/codeql-config.yml +12 -0
- package/.github/workflows/codeql.yml +102 -0
- package/.github/workflows/stale.yml +5 -0
- package/.github/workflows/test.yml +3 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/lib/commands/authenticate.js +5 -5
- package/lib/commands/fetch.js +1 -0
- package/lib/commands/logout.js +1 -0
- package/lib/commands/select.js +2 -1
- package/lib/commands/starttls.js +3 -0
- package/lib/commands/store.js +1 -0
- package/lib/handler/imap-stream.js +23 -6
- package/lib/handler/token-parser.js +0 -5
- package/lib/imap-flow.d.ts +5 -0
- package/lib/imap-flow.js +205 -29
- package/lib/proxy-connection.js +26 -1
- package/lib/tools.js +12 -0
- package/package.json +1 -1
- package/test/commands-branches-test.js +1068 -0
- package/test/connection-edge-cases-test.js +138 -1
- package/test/fixtures/test-tls.js +8 -0
- package/test/handler-branches-test.js +334 -0
- package/test/imap-compiler-test.js +47 -0
- package/test/imap-flow-compress-test.js +154 -0
- package/test/imap-flow-coverage-test.js +609 -0
- package/test/imap-flow-fetch-download-test.js +801 -0
- package/test/imap-flow-internals-test.js +450 -0
- package/test/imap-flow-methods-test.js +738 -0
- package/test/imap-flow-proxy-paths-test.js +215 -0
- package/test/imap-flow-secure-test.js +327 -0
- package/test/imap-flow-server-test.js +1046 -0
- package/test/imap-formal-syntax-test.js +18 -0
- package/test/imap-parser-test.js +52 -0
- package/test/imap-stream-edge-cases-test.js +122 -0
- package/test/jp-decoder-test.js +48 -0
- package/test/limited-passthrough-test.js +17 -0
- package/test/proxy-connection-test.js +86 -0
- package/test/reliability-improvements-test.js +88 -0
- package/test/search-compiler-test.js +31 -0
- package/test/search-test.js +61 -0
- package/test/starttls-injection-test.js +181 -0
- package/test/token-parser-test.js +64 -0
- 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
|
-
|
|
693
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
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
|
|
package/lib/proxy-connection.js
CHANGED
|
@@ -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 };
|