imapflow 1.3.4 → 1.3.6
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 +15 -0
- package/SECURITY.md +88 -0
- package/SECURITY.txt +27 -0
- package/lib/commands/starttls.js +3 -0
- package/lib/commands/store.js +1 -1
- package/lib/handler/imap-stream.js +53 -6
- package/lib/handler/token-parser.js +7 -0
- package/lib/imap-flow.d.ts +11 -0
- package/lib/imap-flow.js +217 -28
- package/lib/proxy-connection.js +26 -1
- package/lib/tools.js +8 -2
- package/package.json +2 -2
- package/test/commands-integration-test.js +45 -0
- package/test/connection-edge-cases-test.js +206 -1
- package/test/imap-stream-edge-cases-test.js +126 -0
- package/test/proxy-connection-test.js +86 -0
- package/test/reliability-improvements-test.js +88 -0
- package/test/starttls-injection-test.js +181 -0
- package/test/token-parser-test.js +18 -0
- package/test/tools-test.js +42 -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,
|
|
@@ -291,13 +291,25 @@ class ImapFlow extends EventEmitter {
|
|
|
291
291
|
logger: this.log,
|
|
292
292
|
cid: this.id,
|
|
293
293
|
logRaw: this.logRaw,
|
|
294
|
-
secureConnection: this.secureConnection
|
|
294
|
+
secureConnection: this.secureConnection,
|
|
295
|
+
maxLineLength: this.options.maxLineLength,
|
|
296
|
+
maxLiteralSize: this.options.maxLiteralSize
|
|
295
297
|
});
|
|
296
298
|
|
|
297
299
|
this.reading = false;
|
|
298
300
|
this.socket = false;
|
|
299
301
|
this.writeSocket = false;
|
|
300
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
|
+
|
|
301
313
|
this.isClosed = false;
|
|
302
314
|
|
|
303
315
|
this.states = states;
|
|
@@ -326,6 +338,10 @@ class ImapFlow extends EventEmitter {
|
|
|
326
338
|
|
|
327
339
|
this.expectCapabilityUpdate = false; // force CAPABILITY after LOGIN
|
|
328
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
|
+
|
|
329
345
|
/**
|
|
330
346
|
* Enabled capabilities. Usually `CONDSTORE` and `UTF8=ACCEPT` if server supports these.
|
|
331
347
|
* @type {Set<string>}
|
|
@@ -404,6 +420,34 @@ class ImapFlow extends EventEmitter {
|
|
|
404
420
|
}
|
|
405
421
|
err._connId = err._connId || this.id;
|
|
406
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
|
+
|
|
407
451
|
this.closeAfter();
|
|
408
452
|
this.emit('error', err);
|
|
409
453
|
}
|
|
@@ -688,8 +732,14 @@ class ImapFlow extends EventEmitter {
|
|
|
688
732
|
// Server acknowledged our literal size with "+", send the actual literal data
|
|
689
733
|
if (parsed.tag === '+' && this.commandParts.length) {
|
|
690
734
|
let content = this.commandParts.shift();
|
|
691
|
-
|
|
692
|
-
|
|
735
|
+
// A write() failure here (e.g. socket closed mid-command) must not propagate
|
|
736
|
+
// out of the loop and skip data.next(), which would stall the parser stream.
|
|
737
|
+
try {
|
|
738
|
+
this.write(content);
|
|
739
|
+
this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
|
|
740
|
+
} catch (err) {
|
|
741
|
+
this.log.warn({ err, cid: this.id });
|
|
742
|
+
}
|
|
693
743
|
data.next();
|
|
694
744
|
continue;
|
|
695
745
|
}
|
|
@@ -724,15 +774,22 @@ class ImapFlow extends EventEmitter {
|
|
|
724
774
|
this.requestTagMap.delete(parsed.tag);
|
|
725
775
|
|
|
726
776
|
if (this.currentRequest && this.currentRequest.tag === parsed.tag) {
|
|
727
|
-
// send next pending command
|
|
777
|
+
// send next pending command. A failure here must not propagate out of the
|
|
778
|
+
// loop and skip data.next() below, which would stall the parser stream.
|
|
728
779
|
this.currentRequest = false;
|
|
729
|
-
|
|
780
|
+
try {
|
|
781
|
+
await this.trySend();
|
|
782
|
+
} catch (err) {
|
|
783
|
+
this.log.warn({ err, cid: this.id });
|
|
784
|
+
}
|
|
730
785
|
}
|
|
731
786
|
|
|
732
787
|
switch (parsed.command.toUpperCase()) {
|
|
733
788
|
case 'OK':
|
|
734
789
|
case 'BYE':
|
|
735
|
-
|
|
790
|
+
// hasTrailingData is forwarded so STARTTLS can detect a plaintext
|
|
791
|
+
// injection (data buffered after the tagged OK, before the handshake).
|
|
792
|
+
await new Promise(resolve => request.resolve({ response: parsed, next: resolve, hasTrailingData: !!data.trailingAfterLine }));
|
|
736
793
|
break;
|
|
737
794
|
|
|
738
795
|
case 'NO':
|
|
@@ -795,7 +852,29 @@ class ImapFlow extends EventEmitter {
|
|
|
795
852
|
}
|
|
796
853
|
|
|
797
854
|
this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err });
|
|
798
|
-
|
|
855
|
+
|
|
856
|
+
// Tracked, abortable wait. Storing the timer lets close() clear it so
|
|
857
|
+
// the back-off never keeps the event loop alive, and storing the resolve
|
|
858
|
+
// lets close() abort the wait promptly (aborted=true) instead of blocking
|
|
859
|
+
// the reader for up to 5 minutes and rejecting long after the connection
|
|
860
|
+
// is gone. Normal expiry resolves with aborted=false.
|
|
861
|
+
let aborted = await new Promise(resolve => {
|
|
862
|
+
this._throttleAbort = resolve;
|
|
863
|
+
this._throttleTimer = setTimeout(() => resolve(false), delayResponse);
|
|
864
|
+
if (typeof this._throttleTimer.unref === 'function') {
|
|
865
|
+
this._throttleTimer.unref();
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
this._throttleTimer = null;
|
|
869
|
+
this._throttleAbort = null;
|
|
870
|
+
|
|
871
|
+
if (aborted) {
|
|
872
|
+
// Connection closed during back-off: reject promptly with a
|
|
873
|
+
// connection error (carrying any server BYE reason) instead of
|
|
874
|
+
// waiting out the throttle delay.
|
|
875
|
+
request.reject(this.createNoConnectionError(this.byeReason));
|
|
876
|
+
break;
|
|
877
|
+
}
|
|
799
878
|
}
|
|
800
879
|
}
|
|
801
880
|
|
|
@@ -1012,7 +1091,13 @@ class ImapFlow extends EventEmitter {
|
|
|
1012
1091
|
this.streamer.compress = true;
|
|
1013
1092
|
this.socket.pipe(this._inflate).pipe(this.streamer);
|
|
1014
1093
|
this._inflate.on('error', err => {
|
|
1015
|
-
|
|
1094
|
+
// Only forward into the streamer while it is alive and still has an error
|
|
1095
|
+
// listener. After close() the streamer is destroyed and its listener removed,
|
|
1096
|
+
// so emitting 'error' would throw an unhandled error and crash the process.
|
|
1097
|
+
// (this.streamer is assigned once in the constructor and never nulled.)
|
|
1098
|
+
if (!this.streamer.destroyed && this.streamer.listenerCount('error')) {
|
|
1099
|
+
this.streamer.emit('error', err);
|
|
1100
|
+
}
|
|
1016
1101
|
});
|
|
1017
1102
|
|
|
1018
1103
|
// For outgoing data, replace the writeSocket with a PassThrough buffer.
|
|
@@ -1138,11 +1223,41 @@ class ImapFlow extends EventEmitter {
|
|
|
1138
1223
|
return this._failSTARTTLS();
|
|
1139
1224
|
}
|
|
1140
1225
|
|
|
1226
|
+
// STARTTLS plaintext-injection guard (RFC 3501 §6.2.1): a compliant server stays
|
|
1227
|
+
// silent after the tagged STARTTLS OK until the TLS handshake, so any data that
|
|
1228
|
+
// followed the OK was injected by a MITM and must not be treated as if it arrived
|
|
1229
|
+
// over TLS. Two complementary best-effort checks fail closed before wrapping the
|
|
1230
|
+
// socket; injection that still races in afterwards corrupts the TLS handshake and
|
|
1231
|
+
// is rejected there instead (with a generic TLS error rather than STARTTLS_INJECTION).
|
|
1232
|
+
const failSTARTTLSInjection = () => {
|
|
1233
|
+
let err = new Error('Server sent data after the STARTTLS response and before the TLS handshake; possible plaintext-injection attack');
|
|
1234
|
+
err.code = 'STARTTLS_INJECTION';
|
|
1235
|
+
err.tlsFailed = true;
|
|
1236
|
+
this.closeAfter();
|
|
1237
|
+
return err;
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
// Check 1: the parser saw more input already buffered right after the tagged OK
|
|
1241
|
+
// (same TCP segment, or an already-queued chunk) — see hasTrailingData / starttls.js.
|
|
1242
|
+
if (this._starttlsHadTrailingData) {
|
|
1243
|
+
throw failSTARTTLSInjection();
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1141
1246
|
// STARTTLS upgrade sequence: detach the plain socket from the parser,
|
|
1142
1247
|
// wrap it in a TLS socket, then reconnect the new TLS socket to the
|
|
1143
1248
|
// parser. The plain socket becomes the underlying transport for TLS.
|
|
1144
1249
|
this.socket.unpipe(this.streamer);
|
|
1250
|
+
|
|
1251
|
+
// Check 2: now that the parser is detached, any bytes still buffered on the plain
|
|
1252
|
+
// socket arrived after the OK and were not consumed by the handshake — i.e. injected.
|
|
1253
|
+
// This catches late/fragmented injection that the parse-time snapshot cannot see.
|
|
1254
|
+
let injectedTail = typeof this.socket.read === 'function' ? this.socket.read() : null;
|
|
1255
|
+
if (injectedTail && injectedTail.length) {
|
|
1256
|
+
throw failSTARTTLSInjection();
|
|
1257
|
+
}
|
|
1145
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;
|
|
1146
1261
|
let socketPlain = this.socket;
|
|
1147
1262
|
let opts = Object.assign(
|
|
1148
1263
|
{
|
|
@@ -1180,6 +1295,24 @@ class ImapFlow extends EventEmitter {
|
|
|
1180
1295
|
reject(err);
|
|
1181
1296
|
}, UPGRADE_TIMEOUT);
|
|
1182
1297
|
|
|
1298
|
+
// A TLS handshake failure (bad certificate, protocol mismatch, etc.) is emitted on the
|
|
1299
|
+
// new TLS socket, not on the plain socket, so it must be handled here. Without this the
|
|
1300
|
+
// upgrade promise would only settle via the timeout and connect() would reject with a
|
|
1301
|
+
// generic "Unexpected close" instead of the actual TLS error.
|
|
1302
|
+
const tlsSocketErrorHandler = err => {
|
|
1303
|
+
clearTimeout(this.connectTimeout);
|
|
1304
|
+
clearTimeout(this.upgradeTimeout);
|
|
1305
|
+
if (!this.upgrading) {
|
|
1306
|
+
// already settled
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
this.upgrading = false;
|
|
1310
|
+
err.tlsFailed = true;
|
|
1311
|
+
this.clearSocketHandlers();
|
|
1312
|
+
this.closeAfter();
|
|
1313
|
+
reject(err);
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1183
1316
|
this.upgrading = true;
|
|
1184
1317
|
this.socket = tls.connect(opts, () => {
|
|
1185
1318
|
try {
|
|
@@ -1208,18 +1341,33 @@ class ImapFlow extends EventEmitter {
|
|
|
1208
1341
|
});
|
|
1209
1342
|
}
|
|
1210
1343
|
|
|
1211
|
-
// Clean up the
|
|
1344
|
+
// Clean up the error handlers after successful upgrade
|
|
1212
1345
|
socketPlain.removeListener('error', socketPlainErrorHandler);
|
|
1346
|
+
this.socket.removeListener('error', tlsSocketErrorHandler);
|
|
1347
|
+
|
|
1348
|
+
// Install the normal socket handlers only now that the handshake
|
|
1349
|
+
// succeeded. Doing this during the handshake would leave both
|
|
1350
|
+
// tlsSocketErrorHandler and the generic _socketError on the socket;
|
|
1351
|
+
// a handshake 'error' would then fire BOTH (EventEmitter clones its
|
|
1352
|
+
// listener array on emit), causing a duplicate error and a possible
|
|
1353
|
+
// unhandled 'error' crash. Keeping tlsSocketErrorHandler as the sole
|
|
1354
|
+
// listener until here guarantees a single error path for the upgrade.
|
|
1355
|
+
this.setSocketHandlers();
|
|
1213
1356
|
|
|
1357
|
+
this._upgradeReject = null;
|
|
1214
1358
|
return resolve(true);
|
|
1215
1359
|
} catch (ex) {
|
|
1216
1360
|
this.emitError(ex);
|
|
1217
1361
|
}
|
|
1218
1362
|
});
|
|
1219
1363
|
|
|
1220
|
-
|
|
1364
|
+
// Registered after tls.connect (the TLS socket now exists). This is the ONLY
|
|
1365
|
+
// error listener during the handshake window; the generic handlers are installed
|
|
1366
|
+
// by setSocketHandlers() inside the success callback above, so a handshake error
|
|
1367
|
+
// has a single error path (tlsSocketErrorHandler -> reject).
|
|
1368
|
+
this.socket.once('error', tlsSocketErrorHandler);
|
|
1221
1369
|
|
|
1222
|
-
this.
|
|
1370
|
+
this.writeSocket = this.socket;
|
|
1223
1371
|
});
|
|
1224
1372
|
|
|
1225
1373
|
if (upgraded && this.expectCapabilityUpdate) {
|
|
@@ -1667,6 +1815,11 @@ class ImapFlow extends EventEmitter {
|
|
|
1667
1815
|
let onConnect = () => {
|
|
1668
1816
|
try {
|
|
1669
1817
|
clearTimeout(this.connectTimeout);
|
|
1818
|
+
|
|
1819
|
+
// ImapFlow now owns the socket; drop the proxy's early error handler
|
|
1820
|
+
// (its "before connection setup" message no longer applies).
|
|
1821
|
+
detachEarlyErrorHandler(socket);
|
|
1822
|
+
|
|
1670
1823
|
this.socket.setKeepAlive(true, 5 * 1000);
|
|
1671
1824
|
this.socket.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT);
|
|
1672
1825
|
|
|
@@ -1777,6 +1930,17 @@ class ImapFlow extends EventEmitter {
|
|
|
1777
1930
|
setImmediate(() => this.close());
|
|
1778
1931
|
}
|
|
1779
1932
|
|
|
1933
|
+
// Builds the standard "connection not available" error, optionally annotated with the
|
|
1934
|
+
// server's BYE reason. Single source of truth so every NoConnection rejection is consistent.
|
|
1935
|
+
createNoConnectionError(byeReason) {
|
|
1936
|
+
const error = new Error('Connection not available');
|
|
1937
|
+
error.code = 'NoConnection';
|
|
1938
|
+
if (byeReason) {
|
|
1939
|
+
error.reason = byeReason;
|
|
1940
|
+
}
|
|
1941
|
+
return error;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1780
1944
|
/**
|
|
1781
1945
|
* Closes TCP connection without notifying the server.
|
|
1782
1946
|
*
|
|
@@ -1794,6 +1958,15 @@ class ImapFlow extends EventEmitter {
|
|
|
1794
1958
|
clearTimeout(this.connectTimeout);
|
|
1795
1959
|
clearTimeout(this.greetingTimeout);
|
|
1796
1960
|
|
|
1961
|
+
// Abort any in-flight throttle back-off so the reader unblocks and the
|
|
1962
|
+
// throttled request is rejected promptly rather than after the full delay.
|
|
1963
|
+
clearTimeout(this._throttleTimer);
|
|
1964
|
+
this._throttleTimer = null;
|
|
1965
|
+
if (typeof this._throttleAbort === 'function') {
|
|
1966
|
+
this._throttleAbort(true);
|
|
1967
|
+
this._throttleAbort = null;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1797
1970
|
this.usable = false;
|
|
1798
1971
|
this.idling = false;
|
|
1799
1972
|
|
|
@@ -1804,6 +1977,11 @@ class ImapFlow extends EventEmitter {
|
|
|
1804
1977
|
this.initialReject = false;
|
|
1805
1978
|
let err = new Error('Unexpected close');
|
|
1806
1979
|
err.code = `ClosedAfterConnect${this.secureConnection ? 'TLS' : 'Text'}`;
|
|
1980
|
+
// Surface the server's BYE reason (e.g. "Too many connections") when the
|
|
1981
|
+
// connection was closed by an untagged BYE, so the caller sees why.
|
|
1982
|
+
if (this.byeReason) {
|
|
1983
|
+
err.reason = this.byeReason;
|
|
1984
|
+
}
|
|
1807
1985
|
// Synchronous rejection is safe: connectPromise.catch(noop) is already
|
|
1808
1986
|
// attached, so the rejection is observed immediately. close() is synchronous,
|
|
1809
1987
|
// so all cleanup completes before any microtask rejection handler runs.
|
|
@@ -1840,15 +2018,8 @@ class ImapFlow extends EventEmitter {
|
|
|
1840
2018
|
}
|
|
1841
2019
|
}
|
|
1842
2020
|
|
|
1843
|
-
// Helper to create connection error
|
|
1844
|
-
const createNoConnectionError = byeReason =>
|
|
1845
|
-
const error = new Error('Connection not available');
|
|
1846
|
-
error.code = 'NoConnection';
|
|
1847
|
-
if (byeReason) {
|
|
1848
|
-
error.reason = byeReason;
|
|
1849
|
-
}
|
|
1850
|
-
return error;
|
|
1851
|
-
};
|
|
2021
|
+
// Helper to create connection error (delegates to the shared builder)
|
|
2022
|
+
const createNoConnectionError = byeReason => this.createNoConnectionError(byeReason);
|
|
1852
2023
|
|
|
1853
2024
|
// Reject pending requests and locks synchronously. Each exec() and
|
|
1854
2025
|
// getMailboxLock() promise already has .catch(noop) attached, so the
|
|
@@ -3829,6 +4000,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3829
4000
|
* @returns {Object} Socket objects
|
|
3830
4001
|
* @returns {Object} return.readSocket The read socket (inflated socket if compression is enabled, raw socket otherwise)
|
|
3831
4002
|
* @returns {Object} return.writeSocket The write socket
|
|
4003
|
+
* @returns {Object} return.socket The raw underlying socket (same as readSocket/writeSocket when compression is disabled)
|
|
3832
4004
|
*/
|
|
3833
4005
|
unbind() {
|
|
3834
4006
|
this.socket.unpipe(this.streamer);
|
|
@@ -3836,15 +4008,32 @@ class ImapFlow extends EventEmitter {
|
|
|
3836
4008
|
this._inflate.unpipe(this.streamer);
|
|
3837
4009
|
}
|
|
3838
4010
|
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
this.
|
|
3843
|
-
|
|
4011
|
+
// Detach all of ImapFlow's socket listeners — the raw socket plus, when
|
|
4012
|
+
// compression is active, the PassThrough writeSocket — so the connection
|
|
4013
|
+
// is fully released to the caller.
|
|
4014
|
+
this.clearSocketHandlers();
|
|
4015
|
+
|
|
4016
|
+
const readSocket = this._inflate || this.socket;
|
|
4017
|
+
const writeSocket = this.writeSocket || this.socket;
|
|
4018
|
+
|
|
4019
|
+
// Defense-in-depth: when compression is active the raw socket is orphaned
|
|
4020
|
+
// (neither readSocket nor writeSocket) yet still live and still the target
|
|
4021
|
+
// of the deflate/writeSocket error forwarders. We just stripped our own
|
|
4022
|
+
// error listener, so any post-unbind error (e.g. an upstream ECONNRESET)
|
|
4023
|
+
// would become an unhandled 'error' that crashes the host process. Attach
|
|
4024
|
+
// a benign listener so the orphaned socket can never throw after handoff.
|
|
4025
|
+
// Non-compression path: socket === readSocket === writeSocket and the
|
|
4026
|
+
// caller owns it directly, so leave it untouched (no behavior change).
|
|
4027
|
+
if (this.socket !== readSocket && this.socket !== writeSocket) {
|
|
4028
|
+
this.socket.on('error', err => {
|
|
4029
|
+
this.log.debug({ msg: 'Suppressed error on unbound socket', err, cid: this.id });
|
|
4030
|
+
});
|
|
4031
|
+
}
|
|
3844
4032
|
|
|
3845
4033
|
return {
|
|
3846
|
-
readSocket
|
|
3847
|
-
writeSocket
|
|
4034
|
+
readSocket,
|
|
4035
|
+
writeSocket,
|
|
4036
|
+
socket: this.socket
|
|
3848
4037
|
};
|
|
3849
4038
|
}
|
|
3850
4039
|
}
|
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 };
|
package/lib/tools.js
CHANGED
|
@@ -494,7 +494,7 @@ const tools = {
|
|
|
494
494
|
|
|
495
495
|
// normalize path to use ascii, so we would always get the same ID
|
|
496
496
|
let path = mailbox.path;
|
|
497
|
-
if (/[
|
|
497
|
+
if (/[\u0080-\uffff]/.test(path)) {
|
|
498
498
|
try {
|
|
499
499
|
path = iconv.encode(path, 'utf-7-imap').toString();
|
|
500
500
|
} catch {
|
|
@@ -502,6 +502,10 @@ const tools = {
|
|
|
502
502
|
}
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
// Non-cryptographic identifier: MD5 is used only to derive a stable, compact
|
|
506
|
+
// account-unique id from non-secret data (path:uidValidity:uid). No security
|
|
507
|
+
// property (collision/preimage resistance, secrecy) is relied upon, so a fast
|
|
508
|
+
// hash is the appropriate choice here — not a security-sensitive use.
|
|
505
509
|
map.id =
|
|
506
510
|
map.emailId ||
|
|
507
511
|
createHash('md5')
|
|
@@ -1069,7 +1073,9 @@ const tools = {
|
|
|
1069
1073
|
return '';
|
|
1070
1074
|
}
|
|
1071
1075
|
|
|
1072
|
-
|
|
1076
|
+
// Deduplicate before sorting so that repeated values do not produce
|
|
1077
|
+
// overlapping/non-canonical tokens (e.g. [1,1,2,3] -> "1:3", not "1,1:3").
|
|
1078
|
+
list = Array.from(new Set(list)).sort((a, b) => a - b);
|
|
1073
1079
|
|
|
1074
1080
|
let last = list[list.length - 1];
|
|
1075
1081
|
let result = [[last]];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.6",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"main": "lib/imap-flow.js",
|
|
6
6
|
"types": "lib/imap-flow.d.ts",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"@eslint/js": "10.0.1",
|
|
31
31
|
"@types/node": "25.9.1",
|
|
32
32
|
"c8": "11.0.0",
|
|
33
|
-
"eslint": "10.4.
|
|
33
|
+
"eslint": "10.4.1",
|
|
34
34
|
"eslint-config-nodemailer": "1.2.0",
|
|
35
35
|
"eslint-config-prettier": "10.1.8",
|
|
36
36
|
"grunt": "1.6.2",
|
|
@@ -582,6 +582,51 @@ module.exports['Commands: store remove flags'] = async test => {
|
|
|
582
582
|
test.done();
|
|
583
583
|
};
|
|
584
584
|
|
|
585
|
+
module.exports['Commands: store remove keeps a flag not in permanentFlags'] = async test => {
|
|
586
|
+
// Mailbox permits only \Seen (no \*), so \Custom is not a permanent flag. Removal must still be
|
|
587
|
+
// sent: a flag does not need to be permitted to be removed. Regression guard — the check used
|
|
588
|
+
// to test the rewritten wire-form operation instead of options.operation and dropped the flag.
|
|
589
|
+
let execArgs = null;
|
|
590
|
+
const connection = createMockConnection({
|
|
591
|
+
state: 3,
|
|
592
|
+
mailbox: { permanentFlags: new Set(['\\Seen']) },
|
|
593
|
+
exec: async (cmd, attrs) => {
|
|
594
|
+
execArgs = { cmd, attrs };
|
|
595
|
+
return { next: () => {} };
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const result = await storeCommand(connection, '1:10', ['\\Custom'], { operation: 'remove' });
|
|
600
|
+
test.equal(result, true);
|
|
601
|
+
test.ok(execArgs, 'a STORE command should be issued');
|
|
602
|
+
test.equal(execArgs.attrs[1].value, '-FLAGS');
|
|
603
|
+
test.deepEqual(
|
|
604
|
+
execArgs.attrs[2].map(flag => flag.value),
|
|
605
|
+
['\\Custom'],
|
|
606
|
+
'the removed flag must be present in the command'
|
|
607
|
+
);
|
|
608
|
+
test.done();
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
module.exports['Commands: store add drops a flag not in permanentFlags'] = async test => {
|
|
612
|
+
// Control for the regression above: the permanentFlags guard must still apply to non-remove
|
|
613
|
+
// operations. Adding a flag the mailbox does not permit yields no command and a false result.
|
|
614
|
+
let execCalled = false;
|
|
615
|
+
const connection = createMockConnection({
|
|
616
|
+
state: 3,
|
|
617
|
+
mailbox: { permanentFlags: new Set(['\\Seen']) },
|
|
618
|
+
exec: async () => {
|
|
619
|
+
execCalled = true;
|
|
620
|
+
return { next: () => {} };
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const result = await storeCommand(connection, '1:10', ['\\Custom'], { operation: 'add' });
|
|
625
|
+
test.equal(result, false, 'adding a non-permitted flag should fail');
|
|
626
|
+
test.equal(execCalled, false, 'no STORE command should be issued');
|
|
627
|
+
test.done();
|
|
628
|
+
};
|
|
629
|
+
|
|
585
630
|
module.exports['Commands: store set flags'] = async test => {
|
|
586
631
|
let execArgs = null;
|
|
587
632
|
const connection = createMockConnection({
|