imapflow 1.5.0 → 1.6.0
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/CLAUDE.md +1 -1
- package/lib/commands/idle.js +197 -104
- package/lib/commands/list.js +15 -7
- package/lib/commands/quota.js +3 -0
- package/lib/commands/select.js +5 -0
- package/lib/commands/status.js +4 -0
- package/lib/connection-deadline.js +98 -0
- package/lib/handler/imap-compiler.js +8 -5
- package/lib/handler/imap-stream.js +141 -50
- package/lib/handler/limits.js +43 -0
- package/lib/handler/token-parser.js +31 -1
- package/lib/imap-flow.d.ts +33 -5
- package/lib/imap-flow.js +575 -281
- package/lib/proxy-connection.js +393 -98
- package/lib/special-use.js +660 -51
- package/lib/tools.js +17 -0
- package/package.json +2 -2
- package/test/commands-branches-test.js +17 -1
- package/test/commands-integration-test.js +24 -2
- package/test/fixtures/fake-timers.js +115 -0
- package/test/handler-branches-test.js +0 -25
- package/test/idle-polling-test.js +349 -0
- package/test/imap-flow-compress-test.js +12 -0
- package/test/imap-flow-coverage-test.js +3 -3
- package/test/imap-flow-internals-test.js +23 -0
- package/test/imap-flow-proxy-paths-test.js +151 -0
- package/test/imap-flow-secure-test.js +159 -0
- package/test/imap-flow-server-test.js +149 -0
- package/test/parser-limits-test.js +274 -0
- package/test/proxy-connection-test.js +553 -442
- package/test/reliability-improvements-test.js +87 -0
- package/test/special-use-test.js +337 -0
- package/test/tag-correlation-test.js +333 -0
- package/test/timer-policy-test.js +214 -0
package/lib/imap-flow.js
CHANGED
|
@@ -24,6 +24,7 @@ const FlowedDecoder = require('@zone-eu/mailsplit/lib/flowed-decoder');
|
|
|
24
24
|
const { PassThrough } = require('stream');
|
|
25
25
|
|
|
26
26
|
const { proxyConnection, detachEarlyErrorHandler } = require('./proxy-connection');
|
|
27
|
+
const { ConnectionDeadline } = require('./connection-deadline');
|
|
27
28
|
|
|
28
29
|
const {
|
|
29
30
|
comparePaths,
|
|
@@ -36,14 +37,14 @@ const {
|
|
|
36
37
|
expandRange,
|
|
37
38
|
AuthenticationFailure,
|
|
38
39
|
getColorFlags,
|
|
39
|
-
hasCapability
|
|
40
|
+
hasCapability,
|
|
41
|
+
unrefTimer
|
|
40
42
|
} = require('./tools');
|
|
41
43
|
|
|
42
44
|
const imapCommands = require('./imap-commands.js');
|
|
43
45
|
|
|
44
46
|
const noop = () => {};
|
|
45
47
|
|
|
46
|
-
const CONNECT_TIMEOUT = 90 * 1000;
|
|
47
48
|
const GREETING_TIMEOUT = 16 * 1000;
|
|
48
49
|
const UPGRADE_TIMEOUT = 10 * 1000;
|
|
49
50
|
|
|
@@ -205,7 +206,21 @@ class ImapFlow extends EventEmitter {
|
|
|
205
206
|
* If `true`, disconnects after successful authentication without performing other actions.
|
|
206
207
|
*
|
|
207
208
|
* @property {String} [proxy]
|
|
208
|
-
* Proxy URL. Supports HTTP CONNECT (`http://`, `https://`) and SOCKS (`socks://`, `socks4://`, `socks5://`).
|
|
209
|
+
* Proxy URL. Supports HTTP CONNECT (`http://`, `https://`) and SOCKS (`socks://`, `socks4://`, `socks4a://`, `socks5://`).
|
|
210
|
+
* IPv6 proxy endpoints use the URL form, e.g. `socks5://[2001:db8::1]:1080`.
|
|
211
|
+
*
|
|
212
|
+
* DNS behaviour depends on the proxy protocol:
|
|
213
|
+
* - `http`/`https`: the destination hostname is sent to the proxy unresolved.
|
|
214
|
+
* - `socks4`: destination hostnames are resolved locally to IPv4, because SOCKS4 carries
|
|
215
|
+
* only IPv4 destination addresses and a hostname would silently become a SOCKS4a
|
|
216
|
+
* request. IPv6 destinations are rejected.
|
|
217
|
+
* - `socks4a`: destination hostnames are sent to the proxy for remote DNS. IPv6
|
|
218
|
+
* destinations are rejected.
|
|
219
|
+
* - `socks`/`socks5`: destination hostnames are sent to the proxy for remote DNS, and
|
|
220
|
+
* IPv4/IPv6 literals are passed through unchanged.
|
|
221
|
+
*
|
|
222
|
+
* The proxy endpoint itself is never resolved by ImapFlow - a hostname endpoint is handed
|
|
223
|
+
* to Node as-is, keeping its normal lookup and connection behaviour.
|
|
209
224
|
*
|
|
210
225
|
* @property {Boolean} [qresync=false]
|
|
211
226
|
* If `true`, enables QRESYNC support so that EXPUNGE notifications include `uid` instead of `seq`.
|
|
@@ -228,7 +243,9 @@ class ImapFlow extends EventEmitter {
|
|
|
228
243
|
* without losing the other auto-enabled extensions.
|
|
229
244
|
*
|
|
230
245
|
* @property {Number} [connectionTimeout=90000]
|
|
231
|
-
* Maximum time (in milliseconds) to wait for
|
|
246
|
+
* Maximum time (in milliseconds) to wait for a usable transport. Covers DNS resolution,
|
|
247
|
+
* proxy negotiation and the TCP/TLS handshake as a single budget, so an expiry in any of
|
|
248
|
+
* those phases rejects with error code `CONNECT_TIMEOUT`. Defaults to 90 seconds.
|
|
232
249
|
*
|
|
233
250
|
* @property {Number} [greetingTimeout=16000]
|
|
234
251
|
* Maximum time (in milliseconds) to wait for the server greeting after a connection is established. Defaults to 16 seconds.
|
|
@@ -294,6 +311,11 @@ class ImapFlow extends EventEmitter {
|
|
|
294
311
|
this.secureConnection = true;
|
|
295
312
|
}
|
|
296
313
|
|
|
314
|
+
// Normalized once so direct TLS, cleartext, proxied and STARTTLS-upgraded transports
|
|
315
|
+
// cannot end up with different inactivity watchdogs. As documented, 0 (and any other
|
|
316
|
+
// falsy or invalid value) means "use the default", not "disable".
|
|
317
|
+
this.socketTimeout = Number(this.options.socketTimeout) || SOCKET_TIMEOUT;
|
|
318
|
+
|
|
297
319
|
this.logRaw = this.options.logRaw;
|
|
298
320
|
this.streamer = new ImapStream({
|
|
299
321
|
logger: this.log,
|
|
@@ -330,6 +352,14 @@ class ImapFlow extends EventEmitter {
|
|
|
330
352
|
this.requestQueue = [];
|
|
331
353
|
this.currentRequest = false;
|
|
332
354
|
|
|
355
|
+
// Count of tagged responses whose tag was never issued by this connection. Tolerated
|
|
356
|
+
// (non-conforming servers do this) but tracked, so the compatibility decision in
|
|
357
|
+
// countUnknownTag() can be revisited with field data instead of guesses. Warnings are
|
|
358
|
+
// emitted at the milestones below (1, 2, 4, 8, ...) so the count stays exact without
|
|
359
|
+
// turning a spraying server into a log flood.
|
|
360
|
+
this._unknownTagCount = 0;
|
|
361
|
+
this._nextUnknownTagWarn = 1;
|
|
362
|
+
|
|
333
363
|
this.writeBytesCounter = 0;
|
|
334
364
|
|
|
335
365
|
this.commandParts = [];
|
|
@@ -436,19 +466,21 @@ class ImapFlow extends EventEmitter {
|
|
|
436
466
|
}
|
|
437
467
|
err._connId = err._connId || this.id;
|
|
438
468
|
|
|
439
|
-
// During a STARTTLS handshake the upgrade
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
469
|
+
// During a STARTTLS handshake the upgrade owns the single error path (its settle()
|
|
470
|
+
// helper). Route the error there so a streamer-originated failure is surfaced with its
|
|
471
|
+
// real code (instead of a generic ClosedAfterConnect*) and cannot hang a verifyOnly
|
|
472
|
+
// connect() waiting on a 'close' that never rejects. Fall back to closing if the upgrade
|
|
473
|
+
// has no pending rejector.
|
|
444
474
|
if (this.upgrading) {
|
|
445
|
-
|
|
446
|
-
this.
|
|
447
|
-
if (typeof
|
|
448
|
-
|
|
449
|
-
this._upgradeReject = null;
|
|
475
|
+
let reject = this._upgradeReject;
|
|
476
|
+
this._upgradeReject = null;
|
|
477
|
+
if (typeof reject === 'function') {
|
|
478
|
+
// settle() clears the upgrade timer and flags, and closes the connection
|
|
450
479
|
reject(err);
|
|
480
|
+
return;
|
|
451
481
|
}
|
|
482
|
+
this.upgrading = false;
|
|
483
|
+
this.closeAfter();
|
|
452
484
|
return;
|
|
453
485
|
}
|
|
454
486
|
|
|
@@ -603,6 +635,13 @@ class ImapFlow extends EventEmitter {
|
|
|
603
635
|
// send each remaining part from this.commandParts.
|
|
604
636
|
this.write(this.commandParts.shift());
|
|
605
637
|
|
|
638
|
+
// The command is on the wire now. Tagged-response correlation requires this, so a server
|
|
639
|
+
// that guesses the next (sequential) tag cannot settle a command during the window between
|
|
640
|
+
// it becoming current and actually being written.
|
|
641
|
+
if (this.currentRequest && this.currentRequest.tag === data.tag) {
|
|
642
|
+
this.currentRequest.sent = true;
|
|
643
|
+
}
|
|
644
|
+
|
|
606
645
|
if (typeof options.onSend === 'function') {
|
|
607
646
|
options.onSend();
|
|
608
647
|
}
|
|
@@ -694,228 +733,369 @@ class ImapFlow extends EventEmitter {
|
|
|
694
733
|
}
|
|
695
734
|
}
|
|
696
735
|
|
|
736
|
+
// Releases a readable stream item exactly once. The item's `next` callback is the parser's
|
|
737
|
+
// backpressure token: until it is called, ImapStream stops feeding the connection. Every
|
|
738
|
+
// path out of response handling - success, handled error, or unexpected throw - has to go
|
|
739
|
+
// through here, otherwise the parser stalls permanently.
|
|
740
|
+
releaseStreamData(data) {
|
|
741
|
+
if (!data || data.released) {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
data.released = true;
|
|
745
|
+
if (typeof data.next === 'function') {
|
|
746
|
+
data.next();
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Records a tagged response whose tag was never issued by this connection. ImapFlow talks
|
|
751
|
+
// to a wide range of non-conforming servers, so this is tolerated rather than terminal, but
|
|
752
|
+
// it must not pass silently. Warnings are emitted for the first occurrence and then at
|
|
753
|
+
// powers of two so a server spraying stray tagged lines cannot flood the log, while the
|
|
754
|
+
// counter itself stays exact and is reported when the connection closes.
|
|
755
|
+
countUnknownTag(tag) {
|
|
756
|
+
if (this.isClosed) {
|
|
757
|
+
// teardown crossover, not a server compatibility signal
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
this._unknownTagCount++;
|
|
762
|
+
if (this._unknownTagCount === this._nextUnknownTagWarn) {
|
|
763
|
+
this._nextUnknownTagWarn *= 2;
|
|
764
|
+
this.log.warn({
|
|
765
|
+
msg: 'Tagged response for an unknown tag',
|
|
766
|
+
tag,
|
|
767
|
+
unknownTagCount: this._unknownTagCount,
|
|
768
|
+
cid: this.id
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Terminally fails the connection on a protocol violation: stop parsing, then report. Both
|
|
774
|
+
// steps are explicit here rather than destroying the parser *with* the error and relying on
|
|
775
|
+
// its error listener to report, so the reporting path does not depend on teardown ordering or
|
|
776
|
+
// on the streamer error handler's suppression list.
|
|
777
|
+
failProtocol(err) {
|
|
778
|
+
if (this.streamer && !this.streamer.destroyed) {
|
|
779
|
+
// Destroyed without an error: nothing after a protocol violation may reach
|
|
780
|
+
// application state, and emitError() below owns reporting.
|
|
781
|
+
this.streamer.destroy();
|
|
782
|
+
}
|
|
783
|
+
this.emitError(err);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Rejects the in-flight request, if any, exactly once. Used when response handling fails in
|
|
787
|
+
// a way that leaves the command's outcome unknown.
|
|
788
|
+
rejectCurrentRequest(err) {
|
|
789
|
+
if (!this.currentRequest) {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
let tag = this.currentRequest.tag;
|
|
793
|
+
this.currentRequest = false;
|
|
794
|
+
let request = this.requestTagMap.get(tag);
|
|
795
|
+
if (request) {
|
|
796
|
+
this.requestTagMap.delete(tag);
|
|
797
|
+
request.reject(err);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
697
801
|
async reader() {
|
|
698
802
|
let data;
|
|
699
803
|
let processedCount = 0;
|
|
700
804
|
while ((data = this.streamer.read()) !== null) {
|
|
701
|
-
let
|
|
805
|
+
let keepReading;
|
|
702
806
|
|
|
703
807
|
try {
|
|
704
|
-
|
|
705
|
-
if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
|
|
706
|
-
let payload = { response: parsed.command };
|
|
707
|
-
|
|
708
|
-
if (
|
|
709
|
-
parsed.attributes &&
|
|
710
|
-
parsed.attributes[0] &&
|
|
711
|
-
parsed.attributes[0].section &&
|
|
712
|
-
parsed.attributes[0].section[0] &&
|
|
713
|
-
parsed.attributes[0].section[0].type === 'ATOM'
|
|
714
|
-
) {
|
|
715
|
-
payload.code = parsed.attributes[0].section[0].value;
|
|
716
|
-
}
|
|
717
|
-
this.emit('response', payload);
|
|
718
|
-
}
|
|
808
|
+
keepReading = await this.handleResponse(data);
|
|
719
809
|
} catch (err) {
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
810
|
+
// Response handling past the parse step (log compilation, response shape
|
|
811
|
+
// assumptions, an untagged handler bug) must never throw out of this loop: the
|
|
812
|
+
// parser would keep waiting on its backpressure callback forever, which is a
|
|
813
|
+
// silent permanent hang. Fail closed instead.
|
|
814
|
+
keepReading = false;
|
|
815
|
+
let error = new Error('Failed to process server response');
|
|
816
|
+
error.code = 'ResponseProcessingFailed';
|
|
817
|
+
error._err = err;
|
|
818
|
+
this.log.error({ msg: 'Failed to process server response', err, cid: this.id });
|
|
819
|
+
this.rejectCurrentRequest(error);
|
|
820
|
+
this.failProtocol(error);
|
|
821
|
+
} finally {
|
|
822
|
+
this.releaseStreamData(data);
|
|
724
823
|
}
|
|
725
824
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}
|
|
825
|
+
if (!keepReading) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
729
828
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
this.log.debug({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
|
|
829
|
+
// Yield to event loop every 10 processed messages to prevent CPU blocking
|
|
830
|
+
processedCount++;
|
|
831
|
+
if (processedCount % 10 === 0) {
|
|
832
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
735
833
|
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Handles a single parsed server response: telemetry, continuation requests, response-code
|
|
839
|
+
* section handlers, untagged handlers and tagged command completion.
|
|
840
|
+
*
|
|
841
|
+
* @param {Object} data - Readable item from the parser stream.
|
|
842
|
+
* @returns {Promise<Boolean>} `true` to keep reading, `false` to stop (connection is failing).
|
|
843
|
+
*/
|
|
844
|
+
async handleResponse(data) {
|
|
845
|
+
let parsed;
|
|
846
|
+
|
|
847
|
+
try {
|
|
848
|
+
parsed = await parser(data.payload, { literals: data.literals });
|
|
849
|
+
if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
|
|
850
|
+
let payload = { response: parsed.command };
|
|
851
|
+
|
|
852
|
+
if (
|
|
853
|
+
parsed.attributes &&
|
|
854
|
+
parsed.attributes[0] &&
|
|
855
|
+
parsed.attributes[0].section &&
|
|
856
|
+
parsed.attributes[0].section[0] &&
|
|
857
|
+
parsed.attributes[0].section[0].type === 'ATOM'
|
|
858
|
+
) {
|
|
859
|
+
payload.code = parsed.attributes[0].section[0].value;
|
|
860
|
+
}
|
|
861
|
+
this.emit('response', payload);
|
|
862
|
+
}
|
|
863
|
+
} catch (err) {
|
|
864
|
+
// can not make sense of this
|
|
865
|
+
this.log.error({ src: 's', msg: data.payload.toString(), err, cid: this.id });
|
|
866
|
+
return true;
|
|
867
|
+
}
|
|
736
868
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
869
|
+
let logCompiled = await compiler(parsed, {
|
|
870
|
+
isLogging: true
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
if (/^\d+$/.test(parsed.command) && parsed.attributes && parsed.attributes[0] && parsed.attributes[0].value === 'FETCH') {
|
|
874
|
+
// too many FETCH responses, might want to filter these out
|
|
875
|
+
this.log.trace({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
|
|
876
|
+
} else {
|
|
877
|
+
this.log.debug({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// IMAP "+" (continuation request) handling. The server sends "+" in two cases:
|
|
881
|
+
// 1. During IDLE or AUTHENTICATE, where a custom handler (onPlusTag) processes it
|
|
882
|
+
// 2. During literal data transfer, where we send the next queued literal chunk
|
|
883
|
+
if (parsed.tag === '+' && this.currentRequest && this.currentRequest.options && typeof this.currentRequest.options.onPlusTag === 'function') {
|
|
884
|
+
try {
|
|
885
|
+
await this.currentRequest.options.onPlusTag(parsed);
|
|
886
|
+
} catch (err) {
|
|
887
|
+
this.log.warn({ err, cid: this.id });
|
|
888
|
+
}
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Server acknowledged our literal size with "+", send the actual literal data
|
|
893
|
+
if (parsed.tag === '+' && this.commandParts.length) {
|
|
894
|
+
let content = this.commandParts.shift();
|
|
895
|
+
// A write() failure here (e.g. socket closed mid-command) must not fail the whole
|
|
896
|
+
// connection; the command's own tagged response or the close path reports it.
|
|
897
|
+
try {
|
|
898
|
+
this.write(content);
|
|
899
|
+
this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
|
|
900
|
+
} catch (err) {
|
|
901
|
+
this.log.warn({ err, cid: this.id });
|
|
902
|
+
}
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
let section = parsed.attributes && parsed.attributes.length && parsed.attributes[0] && !parsed.attributes[0].value && parsed.attributes[0].section;
|
|
907
|
+
if (section && section.length && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
908
|
+
let sectionHandler = this.getSectionHandler(section[0].value.toUpperCase().trim());
|
|
909
|
+
if (sectionHandler) {
|
|
741
910
|
try {
|
|
742
|
-
await
|
|
911
|
+
await sectionHandler(section.slice(1));
|
|
743
912
|
} catch (err) {
|
|
744
913
|
this.log.warn({ err, cid: this.id });
|
|
745
914
|
}
|
|
746
|
-
data.next();
|
|
747
|
-
continue;
|
|
748
915
|
}
|
|
916
|
+
}
|
|
749
917
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
// A write() failure here (e.g. socket closed mid-command) must not propagate
|
|
754
|
-
// out of the loop and skip data.next(), which would stall the parser stream.
|
|
918
|
+
if (parsed.tag === '*' && parsed.command) {
|
|
919
|
+
let untaggedHandler = this.getUntaggedHandler(parsed.command, parsed.attributes);
|
|
920
|
+
if (untaggedHandler) {
|
|
755
921
|
try {
|
|
756
|
-
|
|
757
|
-
this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
|
|
922
|
+
await untaggedHandler(parsed);
|
|
758
923
|
} catch (err) {
|
|
759
924
|
this.log.warn({ err, cid: this.id });
|
|
925
|
+
return true;
|
|
760
926
|
}
|
|
761
|
-
data.next();
|
|
762
|
-
continue;
|
|
763
927
|
}
|
|
928
|
+
}
|
|
764
929
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
}
|
|
930
|
+
// Tagged response correlation. A tagged response may only complete the command that was
|
|
931
|
+
// actually written to the socket (invariant 2), so the three cases below are kept apart:
|
|
932
|
+
// the active command completes, a command that has not been written yet is proof of
|
|
933
|
+
// desynchronization (queued behind another command, or current but not yet on the wire),
|
|
934
|
+
// and an entirely unknown tag is recorded but tolerated.
|
|
935
|
+
if (parsed.tag && !['*', '+'].includes(parsed.tag)) {
|
|
936
|
+
if (this.currentRequest && this.currentRequest.tag === parsed.tag && this.currentRequest.sent) {
|
|
937
|
+
let request = this.requestTagMap.get(parsed.tag);
|
|
938
|
+
this.requestTagMap.delete(parsed.tag);
|
|
939
|
+
this.currentRequest = false;
|
|
776
940
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
if (untaggedHandler) {
|
|
780
|
-
try {
|
|
781
|
-
await untaggedHandler(parsed);
|
|
782
|
-
} catch (err) {
|
|
783
|
-
this.log.warn({ err, cid: this.id });
|
|
784
|
-
data.next();
|
|
785
|
-
continue;
|
|
786
|
-
}
|
|
941
|
+
if (request) {
|
|
942
|
+
await this.settleRequest(request, parsed, !!data.trailingAfterLine);
|
|
787
943
|
}
|
|
788
|
-
}
|
|
789
944
|
|
|
790
|
-
|
|
945
|
+
// Send the next queued command only after the completed command's handler has
|
|
946
|
+
// applied its own state (e.g. select.js publishing the new mailbox), so the next
|
|
947
|
+
// command cannot reach the wire against half-updated state. A failure here must
|
|
948
|
+
// not propagate, or the whole connection would be failed over a send error that
|
|
949
|
+
// the command's own promise already reports.
|
|
950
|
+
// Note: on a rejected command the handler's catch block runs on its own microtask
|
|
951
|
+
// chain, so only the success path is fully ordered.
|
|
952
|
+
try {
|
|
953
|
+
await this.trySend();
|
|
954
|
+
} catch (err) {
|
|
955
|
+
this.log.warn({ err, cid: this.id });
|
|
956
|
+
}
|
|
957
|
+
} else if (this.requestTagMap.has(parsed.tag)) {
|
|
958
|
+
// The server answered a command that has not been written to the socket yet.
|
|
959
|
+
// Continuing would report unsent mutations as successful and leave every later
|
|
960
|
+
// response ambiguous, so reject this request and fail the connection closed.
|
|
791
961
|
let request = this.requestTagMap.get(parsed.tag);
|
|
792
962
|
this.requestTagMap.delete(parsed.tag);
|
|
793
963
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
} catch (err) {
|
|
801
|
-
this.log.warn({ err, cid: this.id });
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
switch (parsed.command.toUpperCase()) {
|
|
806
|
-
case 'OK':
|
|
807
|
-
case 'BYE':
|
|
808
|
-
// hasTrailingData is forwarded so STARTTLS can detect a plaintext
|
|
809
|
-
// injection (data buffered after the tagged OK, before the handshake).
|
|
810
|
-
await new Promise(resolve => request.resolve({ response: parsed, next: resolve, hasTrailingData: !!data.trailingAfterLine }));
|
|
811
|
-
break;
|
|
964
|
+
let err = new Error('Server sent a tagged response for a command that was not in flight');
|
|
965
|
+
err.code = 'UnexpectedTag';
|
|
966
|
+
err.details = {
|
|
967
|
+
received: parsed.tag,
|
|
968
|
+
expected: this.currentRequest ? this.currentRequest.tag : null
|
|
969
|
+
};
|
|
812
970
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
971
|
+
this.log.error({ msg: 'Protocol desynchronization', err, cid: this.id });
|
|
972
|
+
request.reject(err);
|
|
973
|
+
this.failProtocol(err);
|
|
974
|
+
return false;
|
|
975
|
+
} else {
|
|
976
|
+
this.countUnknownTag(parsed.tag);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
821
979
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
err.responseStatus = parsed.command.toUpperCase();
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
825
982
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
983
|
+
/**
|
|
984
|
+
* Settles a request with its tagged completion response.
|
|
985
|
+
*
|
|
986
|
+
* On success the returned promise stays pending until the command handler calls `next()` on
|
|
987
|
+
* the response, which is what orders state application before the next queued command is
|
|
988
|
+
* dispatched. A command handler must therefore always release its own response before
|
|
989
|
+
* awaiting another command on the same connection.
|
|
990
|
+
*
|
|
991
|
+
* @param {Object} request - Pending request entry (resolve/reject and the compiled command).
|
|
992
|
+
* @param {Object} parsed - Parsed tagged response.
|
|
993
|
+
* @param {Boolean} hasTrailingData - Whether more input was already buffered after this line.
|
|
994
|
+
* @returns {Promise<void>}
|
|
995
|
+
*/
|
|
996
|
+
async settleRequest(request, parsed, hasTrailingData) {
|
|
997
|
+
switch ((parsed.command || '').toUpperCase()) {
|
|
998
|
+
case 'OK':
|
|
999
|
+
case 'BYE':
|
|
1000
|
+
// hasTrailingData is forwarded so STARTTLS can detect a plaintext
|
|
1001
|
+
// injection (data buffered after the tagged OK, before the handshake).
|
|
1002
|
+
await new Promise(resolve => request.resolve({ response: parsed, next: resolve, hasTrailingData }));
|
|
1003
|
+
break;
|
|
837
1004
|
|
|
838
|
-
|
|
839
|
-
|
|
1005
|
+
case 'NO':
|
|
1006
|
+
case 'BAD': {
|
|
1007
|
+
let txt =
|
|
1008
|
+
parsed.attributes &&
|
|
1009
|
+
parsed.attributes
|
|
1010
|
+
.filter(val => val.type === 'TEXT')
|
|
1011
|
+
.map(val => val.value.trim())
|
|
1012
|
+
.join(' ');
|
|
840
1013
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
await new Promise(resolve => request.resolve({ response: parsed, next: resolve }));
|
|
845
|
-
break;
|
|
846
|
-
}
|
|
1014
|
+
let err = new Error('Command failed');
|
|
1015
|
+
err.response = parsed;
|
|
1016
|
+
err.responseStatus = parsed.command.toUpperCase();
|
|
847
1017
|
|
|
848
|
-
|
|
1018
|
+
try {
|
|
1019
|
+
err.executedCommand =
|
|
1020
|
+
parsed.tag +
|
|
1021
|
+
(
|
|
1022
|
+
await compiler(request, {
|
|
1023
|
+
isLogging: true
|
|
1024
|
+
})
|
|
1025
|
+
).toString();
|
|
1026
|
+
} catch {
|
|
1027
|
+
// ignore
|
|
1028
|
+
}
|
|
849
1029
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
// Example: "tag BAD Request is throttled. Suggested Backoff Time: 92415 milliseconds"
|
|
853
|
-
if (/Request is throttled/i.test(txt) && /Backoff Time/i.test(txt)) {
|
|
854
|
-
let throttlingMatch = txt.match(/Backoff Time[:=\s]+(\d+)/i);
|
|
855
|
-
if (throttlingMatch && throttlingMatch[1] && !isNaN(throttlingMatch[1])) {
|
|
856
|
-
throttleDelay = Number(throttlingMatch[1]);
|
|
857
|
-
}
|
|
858
|
-
}
|
|
1030
|
+
if (txt) {
|
|
1031
|
+
err.responseText = txt;
|
|
859
1032
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1033
|
+
if (err.responseStatus === 'NO' && txt.includes('Some of the requested messages no longer exist')) {
|
|
1034
|
+
// Treat as successful response
|
|
1035
|
+
this.log.warn({ msg: 'Partial FETCH response', cid: this.id, err });
|
|
1036
|
+
await new Promise(resolve => request.resolve({ response: parsed, next: resolve }));
|
|
1037
|
+
break;
|
|
1038
|
+
}
|
|
864
1039
|
|
|
865
|
-
|
|
866
|
-
if (delayResponse > 5 * 60 * 1000) {
|
|
867
|
-
// Cap wait at 5 minutes to avoid hanging connections indefinitely.
|
|
868
|
-
// The server-suggested delay can be very large.
|
|
869
|
-
delayResponse = 5 * 60 * 1000;
|
|
870
|
-
}
|
|
1040
|
+
let throttleDelay = false;
|
|
871
1041
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
let aborted = await new Promise(resolve => {
|
|
880
|
-
this._throttleAbort = resolve;
|
|
881
|
-
this._throttleTimer = setTimeout(() => resolve(false), delayResponse);
|
|
882
|
-
if (typeof this._throttleTimer.unref === 'function') {
|
|
883
|
-
this._throttleTimer.unref();
|
|
884
|
-
}
|
|
885
|
-
});
|
|
886
|
-
this._throttleTimer = null;
|
|
887
|
-
this._throttleAbort = null;
|
|
888
|
-
|
|
889
|
-
if (aborted) {
|
|
890
|
-
// Connection closed during back-off: reject promptly with a
|
|
891
|
-
// connection error (carrying any server BYE reason) instead of
|
|
892
|
-
// waiting out the throttle delay.
|
|
893
|
-
request.reject(this.createNoConnectionError(this.byeReason));
|
|
894
|
-
break;
|
|
895
|
-
}
|
|
896
|
-
}
|
|
1042
|
+
// MS365 throttling detection: Office 365 returns BAD with a human-readable
|
|
1043
|
+
// backoff time when rate limits are hit. Parse the delay from the response text.
|
|
1044
|
+
// Example: "tag BAD Request is throttled. Suggested Backoff Time: 92415 milliseconds"
|
|
1045
|
+
if (/Request is throttled/i.test(txt) && /Backoff Time/i.test(txt)) {
|
|
1046
|
+
let throttlingMatch = txt.match(/Backoff Time[:=\s]+(\d+)/i);
|
|
1047
|
+
if (throttlingMatch && throttlingMatch[1] && !isNaN(throttlingMatch[1])) {
|
|
1048
|
+
throttleDelay = Number(throttlingMatch[1]);
|
|
897
1049
|
}
|
|
898
|
-
|
|
899
|
-
request.reject(err);
|
|
900
|
-
break;
|
|
901
1050
|
}
|
|
902
1051
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
err.code = '
|
|
906
|
-
err.
|
|
907
|
-
|
|
908
|
-
|
|
1052
|
+
// Wait and return a throttling error
|
|
1053
|
+
if (throttleDelay) {
|
|
1054
|
+
err.code = 'ETHROTTLE';
|
|
1055
|
+
err.throttleReset = throttleDelay;
|
|
1056
|
+
|
|
1057
|
+
let delayResponse = throttleDelay;
|
|
1058
|
+
if (delayResponse > 5 * 60 * 1000) {
|
|
1059
|
+
// Cap wait at 5 minutes to avoid hanging connections indefinitely.
|
|
1060
|
+
// The server-suggested delay can be very large.
|
|
1061
|
+
delayResponse = 5 * 60 * 1000;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err });
|
|
1065
|
+
|
|
1066
|
+
// Tracked, abortable wait. Storing the timer lets close() clear it so
|
|
1067
|
+
// the back-off never keeps the event loop alive, and storing the resolve
|
|
1068
|
+
// lets close() abort the wait promptly (aborted=true) instead of blocking
|
|
1069
|
+
// the reader for up to 5 minutes and rejecting long after the connection
|
|
1070
|
+
// is gone. Normal expiry resolves with aborted=false.
|
|
1071
|
+
let aborted = await new Promise(resolve => {
|
|
1072
|
+
this._throttleAbort = resolve;
|
|
1073
|
+
this._throttleTimer = setTimeout(() => resolve(false), delayResponse);
|
|
1074
|
+
unrefTimer(this._throttleTimer);
|
|
1075
|
+
});
|
|
1076
|
+
this._throttleTimer = null;
|
|
1077
|
+
this._throttleAbort = null;
|
|
1078
|
+
|
|
1079
|
+
if (aborted) {
|
|
1080
|
+
// Connection closed during back-off: reject promptly with a
|
|
1081
|
+
// connection error (carrying any server BYE reason) instead of
|
|
1082
|
+
// waiting out the throttle delay.
|
|
1083
|
+
request.reject(this.createNoConnectionError(this.byeReason));
|
|
1084
|
+
break;
|
|
1085
|
+
}
|
|
909
1086
|
}
|
|
910
1087
|
}
|
|
911
|
-
}
|
|
912
1088
|
|
|
913
|
-
|
|
1089
|
+
request.reject(err);
|
|
1090
|
+
break;
|
|
1091
|
+
}
|
|
914
1092
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1093
|
+
default: {
|
|
1094
|
+
let err = new Error('Invalid server response');
|
|
1095
|
+
err.code = 'InvalidResponse';
|
|
1096
|
+
err.response = parsed;
|
|
1097
|
+
request.reject(err);
|
|
1098
|
+
break;
|
|
919
1099
|
}
|
|
920
1100
|
}
|
|
921
1101
|
}
|
|
@@ -939,6 +1119,29 @@ class ImapFlow extends EventEmitter {
|
|
|
939
1119
|
this.streamer.on('readable', this.socketReadable);
|
|
940
1120
|
}
|
|
941
1121
|
|
|
1122
|
+
/**
|
|
1123
|
+
* Applies the transport options every established application socket needs: TCP keepalive and
|
|
1124
|
+
* the inactivity watchdog. Called for direct TLS, cleartext, proxied and STARTTLS-upgraded
|
|
1125
|
+
* sockets, so the watchdog cannot silently differ between transports (a STARTTLS session used
|
|
1126
|
+
* to end up with no armed timer at all).
|
|
1127
|
+
*
|
|
1128
|
+
* @param {Object} socket - The socket that now carries the IMAP session.
|
|
1129
|
+
*/
|
|
1130
|
+
configureSocket(socket) {
|
|
1131
|
+
/* c8 ignore next 3 */ // defensive: connect() only calls this with an established socket
|
|
1132
|
+
if (!socket) {
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (typeof socket.setKeepAlive === 'function') {
|
|
1137
|
+
socket.setKeepAlive(true, 5 * 1000);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (typeof socket.setTimeout === 'function') {
|
|
1141
|
+
socket.setTimeout(this.socketTimeout);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
942
1145
|
setSocketHandlers() {
|
|
943
1146
|
// Clear any existing handlers first to prevent duplicates
|
|
944
1147
|
this.clearSocketHandlers();
|
|
@@ -971,7 +1174,11 @@ class ImapFlow extends EventEmitter {
|
|
|
971
1174
|
this.emitError(err);
|
|
972
1175
|
return;
|
|
973
1176
|
}
|
|
974
|
-
// Attempt to recover IDLE connections
|
|
1177
|
+
// Attempt to recover IDLE connections. During true IDLE the NOOP cannot
|
|
1178
|
+
// reach the server until IDLE has been terminated: run() awaits preCheck(),
|
|
1179
|
+
// which sends DONE and only resolves once the server has completed the IDLE
|
|
1180
|
+
// command. Fallback polling has no such handshake - preCheck() there just
|
|
1181
|
+
// cancels the polling session.
|
|
975
1182
|
this.run('NOOP')
|
|
976
1183
|
.then(() => this.idle())
|
|
977
1184
|
.catch(err => {
|
|
@@ -1150,9 +1357,10 @@ class ImapFlow extends EventEmitter {
|
|
|
1150
1357
|
};
|
|
1151
1358
|
/* c8 ignore stop */
|
|
1152
1359
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1360
|
+
// The PassThrough reports its own `destroyed` state. It used to proxy the raw socket's
|
|
1361
|
+
// instead, which made close() skip destroying it and left the second raw-socket teardown
|
|
1362
|
+
// branch unreachable. write() checks the raw socket separately, so nothing depends on the
|
|
1363
|
+
// two states being conflated.
|
|
1156
1364
|
|
|
1157
1365
|
// Manual pump loop: reads chunks from writeSocket, pushes them into
|
|
1158
1366
|
// deflate, and flushes when the buffer is drained. This ensures each
|
|
@@ -1289,8 +1497,6 @@ class ImapFlow extends EventEmitter {
|
|
|
1289
1497
|
throw failSTARTTLSInjection();
|
|
1290
1498
|
}
|
|
1291
1499
|
let upgraded = await new Promise((resolve, reject) => {
|
|
1292
|
-
// Expose this rejector so emitError() can settle the upgrade with a streamer error.
|
|
1293
|
-
this._upgradeReject = reject;
|
|
1294
1500
|
let socketPlain = this.socket;
|
|
1295
1501
|
let opts = Object.assign(
|
|
1296
1502
|
{
|
|
@@ -1302,71 +1508,73 @@ class ImapFlow extends EventEmitter {
|
|
|
1302
1508
|
);
|
|
1303
1509
|
this.clearSocketHandlers();
|
|
1304
1510
|
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1511
|
+
let settled = false;
|
|
1512
|
+
|
|
1513
|
+
// Single settlement path for the upgrade. Every terminal outcome - handshake
|
|
1514
|
+
// success, an error on the plain or the TLS socket, the upgrade timeout, an
|
|
1515
|
+
// explicit close(), or a streamer error routed here by emitError() - goes through
|
|
1516
|
+
// this helper exactly once. It owns clearing the upgrade timer, the exposed
|
|
1517
|
+
// rejector, the `upgrading` flag and the temporary handshake handlers, so a late
|
|
1518
|
+
// socket event cannot re-enter an already settled upgrade or leave state behind.
|
|
1519
|
+
const settle = (err, result) => {
|
|
1520
|
+
if (settled) {
|
|
1312
1521
|
return;
|
|
1313
1522
|
}
|
|
1314
|
-
|
|
1523
|
+
settled = true;
|
|
1524
|
+
|
|
1525
|
+
clearTimeout(this.upgradeTimeout);
|
|
1526
|
+
this.upgradeTimeout = null;
|
|
1315
1527
|
this.upgrading = false;
|
|
1316
|
-
|
|
1317
|
-
|
|
1528
|
+
this._upgradeReject = null;
|
|
1529
|
+
|
|
1530
|
+
socketPlain.removeListener('error', settle);
|
|
1531
|
+
if (this.socket && this.socket !== socketPlain) {
|
|
1532
|
+
this.socket.removeListener('error', settle);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
if (err) {
|
|
1536
|
+
clearTimeout(this.connectTimeout);
|
|
1537
|
+
// Preserve the original error, marked as a TLS failure so callers can tell
|
|
1538
|
+
// an upgrade failure from an ordinary command failure.
|
|
1539
|
+
err.tlsFailed = true;
|
|
1540
|
+
this.closeAfter();
|
|
1541
|
+
return reject(err);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
resolve(result);
|
|
1318
1545
|
};
|
|
1319
|
-
|
|
1320
|
-
|
|
1546
|
+
|
|
1547
|
+
// Exposed so emitError() and close() can settle the upgrade through the same path.
|
|
1548
|
+
this._upgradeReject = settle;
|
|
1549
|
+
|
|
1550
|
+
// An error on either socket settles the upgrade, so settle() is the listener itself:
|
|
1551
|
+
// one function, one settlement, and removeListener() in settle() needs no separate
|
|
1552
|
+
// handler references. A TLS handshake failure (bad certificate, protocol mismatch)
|
|
1553
|
+
// is emitted on the new TLS socket rather than on the plain one, so both are covered.
|
|
1554
|
+
socketPlain.once('error', settle);
|
|
1321
1555
|
|
|
1322
1556
|
/* c8 ignore start */ // UPGRADE_TIMEOUT is 10s; firing it deterministically would make the test suite hang
|
|
1323
1557
|
this.upgradeTimeout = setTimeout(() => {
|
|
1324
|
-
if (!this.upgrading) {
|
|
1325
|
-
return;
|
|
1326
|
-
}
|
|
1327
|
-
this.closeAfter();
|
|
1328
1558
|
let err = new Error('Failed to upgrade connection in required time');
|
|
1329
|
-
err.tlsFailed = true;
|
|
1330
1559
|
err.code = 'UPGRADE_TIMEOUT';
|
|
1331
|
-
|
|
1560
|
+
settle(err);
|
|
1332
1561
|
}, UPGRADE_TIMEOUT);
|
|
1333
1562
|
/* c8 ignore stop */
|
|
1334
1563
|
|
|
1335
|
-
// A TLS handshake failure (bad certificate, protocol mismatch, etc.) is emitted on the
|
|
1336
|
-
// new TLS socket, not on the plain socket, so it must be handled here. Without this the
|
|
1337
|
-
// upgrade promise would only settle via the timeout and connect() would reject with a
|
|
1338
|
-
// generic "Unexpected close" instead of the actual TLS error.
|
|
1339
|
-
const tlsSocketErrorHandler = err => {
|
|
1340
|
-
clearTimeout(this.connectTimeout);
|
|
1341
|
-
clearTimeout(this.upgradeTimeout);
|
|
1342
|
-
/* c8 ignore start */ // the already-settled early return is a defensive double-fire guard, not separately exercised
|
|
1343
|
-
if (!this.upgrading) {
|
|
1344
|
-
// already settled
|
|
1345
|
-
return;
|
|
1346
|
-
}
|
|
1347
|
-
/* c8 ignore stop */
|
|
1348
|
-
this.upgrading = false;
|
|
1349
|
-
err.tlsFailed = true;
|
|
1350
|
-
this.clearSocketHandlers();
|
|
1351
|
-
this.closeAfter();
|
|
1352
|
-
reject(err);
|
|
1353
|
-
};
|
|
1354
|
-
|
|
1355
1564
|
this.upgrading = true;
|
|
1356
1565
|
this.socket = tls.connect(opts, () => {
|
|
1357
1566
|
try {
|
|
1358
|
-
clearTimeout(this.upgradeTimeout);
|
|
1359
1567
|
/* c8 ignore start */ // race: connection closed during the TLS handshake window
|
|
1360
1568
|
if (this.isClosed) {
|
|
1361
|
-
|
|
1362
|
-
|
|
1569
|
+
let err = new Error('Connection closed during TLS upgrade');
|
|
1570
|
+
err.code = 'NoConnection';
|
|
1571
|
+
return settle(err);
|
|
1363
1572
|
}
|
|
1364
1573
|
/* c8 ignore stop */
|
|
1365
1574
|
|
|
1366
1575
|
// TLS handshake complete. Reconnect the now-encrypted socket
|
|
1367
1576
|
// to the IMAP parser stream and record the cipher details.
|
|
1368
1577
|
this.secureConnection = true;
|
|
1369
|
-
this.upgrading = false;
|
|
1370
1578
|
this.streamer.secureConnection = true;
|
|
1371
1579
|
this.socket.pipe(this.streamer);
|
|
1372
1580
|
/* c8 ignore next */ // an upgraded TLS socket always exposes getCipher(), so the false fallback is unreachable
|
|
@@ -1384,21 +1592,28 @@ class ImapFlow extends EventEmitter {
|
|
|
1384
1592
|
});
|
|
1385
1593
|
}
|
|
1386
1594
|
|
|
1387
|
-
//
|
|
1388
|
-
|
|
1389
|
-
|
|
1595
|
+
// The plain socket is now only the TLS transport: drop its superseded
|
|
1596
|
+
// inactivity timer so no armed timer is left behind without a listener.
|
|
1597
|
+
if (typeof socketPlain.setTimeout === 'function') {
|
|
1598
|
+
socketPlain.setTimeout(0);
|
|
1599
|
+
}
|
|
1390
1600
|
|
|
1391
1601
|
// Install the normal socket handlers only now that the handshake
|
|
1392
|
-
// succeeded. Doing this during the handshake would leave both
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
//
|
|
1396
|
-
// unhandled 'error' crash. Keeping tlsSocketErrorHandler as the sole
|
|
1602
|
+
// succeeded. Doing this during the handshake would leave both settle() and
|
|
1603
|
+
// the generic _socketError on the socket; a handshake 'error' would then fire
|
|
1604
|
+
// BOTH (EventEmitter clones its listener array on emit), causing a duplicate
|
|
1605
|
+
// error and a possible unhandled 'error' crash. Keeping settle() as the sole
|
|
1397
1606
|
// listener until here guarantees a single error path for the upgrade.
|
|
1398
1607
|
this.setSocketHandlers();
|
|
1399
1608
|
|
|
1400
|
-
|
|
1401
|
-
|
|
1609
|
+
// Arm the inactivity watchdog on the socket that now carries the session.
|
|
1610
|
+
// Without this a STARTTLS-upgraded connection has no watchdog at all: the
|
|
1611
|
+
// timer was armed on the plain socket, while the timeout listener lives on
|
|
1612
|
+
// the TLS socket.
|
|
1613
|
+
this.configureSocket(this.socket);
|
|
1614
|
+
|
|
1615
|
+
// settle() also removes the temporary handshake handlers
|
|
1616
|
+
settle(null, true);
|
|
1402
1617
|
/* c8 ignore next 3 */ // defensive: the success callback body does not throw under normal operation
|
|
1403
1618
|
} catch (ex) {
|
|
1404
1619
|
this.emitError(ex);
|
|
@@ -1408,8 +1623,8 @@ class ImapFlow extends EventEmitter {
|
|
|
1408
1623
|
// Registered after tls.connect (the TLS socket now exists). This is the ONLY
|
|
1409
1624
|
// error listener during the handshake window; the generic handlers are installed
|
|
1410
1625
|
// by setSocketHandlers() inside the success callback above, so a handshake error
|
|
1411
|
-
// has a single error path
|
|
1412
|
-
this.socket.once('error',
|
|
1626
|
+
// has a single error path.
|
|
1627
|
+
this.socket.once('error', settle);
|
|
1413
1628
|
|
|
1414
1629
|
this.writeSocket = this.socket;
|
|
1415
1630
|
});
|
|
@@ -1778,6 +1993,11 @@ class ImapFlow extends EventEmitter {
|
|
|
1778
1993
|
return range;
|
|
1779
1994
|
}
|
|
1780
1995
|
|
|
1996
|
+
// Timer process-liveness policy: connection establishment and greeting deadlines keep the
|
|
1997
|
+
// process alive, because a caller is waiting on connect() to settle. Background timers
|
|
1998
|
+
// (auto-IDLE, IDLE restart, fallback polling, throttle back-off, the held-lock diagnostic) are
|
|
1999
|
+
// unref'd, so an otherwise idle process is not held open by them. Every timer is still cleared
|
|
2000
|
+
// explicitly on close().
|
|
1781
2001
|
autoidle() {
|
|
1782
2002
|
clearTimeout(this.idleStartTimer);
|
|
1783
2003
|
if (this.options.disableAutoIdle || this.state !== this.states.SELECTED) {
|
|
@@ -1786,6 +2006,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1786
2006
|
this.idleStartTimer = setTimeout(() => {
|
|
1787
2007
|
this.idle().catch(err => this.log.warn({ err, cid: this.id }));
|
|
1788
2008
|
}, 15 * 1000);
|
|
2009
|
+
unrefTimer(this.idleStartTimer);
|
|
1789
2010
|
}
|
|
1790
2011
|
|
|
1791
2012
|
// PUBLIC API METHODS
|
|
@@ -1806,6 +2027,11 @@ class ImapFlow extends EventEmitter {
|
|
|
1806
2027
|
}
|
|
1807
2028
|
this._connectCalled = true;
|
|
1808
2029
|
|
|
2030
|
+
// One deadline for the whole attempt, started before anything is resolved or negotiated.
|
|
2031
|
+
// Proxy DNS and proxy negotiation used to run entirely outside the timer, so a stalled
|
|
2032
|
+
// proxy could hang far beyond the documented connectionTimeout.
|
|
2033
|
+
let deadline = new ConnectionDeadline(this.options.connectionTimeout);
|
|
2034
|
+
|
|
1809
2035
|
let connector = this.secureConnection ? tls : net;
|
|
1810
2036
|
|
|
1811
2037
|
let opts = Object.assign(
|
|
@@ -1834,11 +2060,17 @@ class ImapFlow extends EventEmitter {
|
|
|
1834
2060
|
let socket = false;
|
|
1835
2061
|
if (this.options.proxy) {
|
|
1836
2062
|
try {
|
|
1837
|
-
socket = await proxyConnection(this.log, this.options.proxy, this.host, this.port);
|
|
2063
|
+
socket = await proxyConnection(this.log, this.options.proxy, this.host, this.port, { deadline });
|
|
1838
2064
|
if (!socket) {
|
|
1839
2065
|
throw new Error('Failed to setup proxy connection');
|
|
1840
2066
|
}
|
|
1841
2067
|
} catch (err) {
|
|
2068
|
+
if (err.code === 'CONNECT_TIMEOUT') {
|
|
2069
|
+
// The shared deadline expired during proxy setup. Report it as the documented
|
|
2070
|
+
// connection timeout rather than as a generic proxy failure.
|
|
2071
|
+
this.log.error({ err, cid: this.id });
|
|
2072
|
+
throw err;
|
|
2073
|
+
}
|
|
1842
2074
|
let error = new Error('Failed to setup proxy connection');
|
|
1843
2075
|
error.code = err.code || 'ProxyError';
|
|
1844
2076
|
error._err = err;
|
|
@@ -1848,17 +2080,13 @@ class ImapFlow extends EventEmitter {
|
|
|
1848
2080
|
}
|
|
1849
2081
|
|
|
1850
2082
|
let connectPromise = new Promise((resolve, reject) => {
|
|
2083
|
+
// Whatever the proxy phase already used is gone from the budget
|
|
1851
2084
|
this.connectTimeout = setTimeout(() => {
|
|
1852
|
-
let err =
|
|
1853
|
-
err.code = 'CONNECT_TIMEOUT';
|
|
1854
|
-
err.details = {
|
|
1855
|
-
/* c8 ignore next */ // firing the timeout with the default (large) value would hang the suite, so only the explicit-option path is tested
|
|
1856
|
-
connectionTimeout: this.options.connectionTimeout || CONNECT_TIMEOUT
|
|
1857
|
-
};
|
|
2085
|
+
let err = deadline.error();
|
|
1858
2086
|
this.log.error({ err, cid: this.id });
|
|
1859
2087
|
this.closeAfter();
|
|
1860
2088
|
reject(err);
|
|
1861
|
-
},
|
|
2089
|
+
}, deadline.remaining());
|
|
1862
2090
|
|
|
1863
2091
|
let onConnect = () => {
|
|
1864
2092
|
try {
|
|
@@ -1868,8 +2096,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1868
2096
|
// (its "before connection setup" message no longer applies).
|
|
1869
2097
|
detachEarlyErrorHandler(socket);
|
|
1870
2098
|
|
|
1871
|
-
this.socket
|
|
1872
|
-
this.socket.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT);
|
|
2099
|
+
this.configureSocket(this.socket);
|
|
1873
2100
|
|
|
1874
2101
|
this.greetingTimeout = setTimeout(() => {
|
|
1875
2102
|
let err = new Error(
|
|
@@ -2020,8 +2247,23 @@ class ImapFlow extends EventEmitter {
|
|
|
2020
2247
|
}
|
|
2021
2248
|
|
|
2022
2249
|
this.usable = false;
|
|
2250
|
+
// close() takes over ownership of the idling state: dropping the session token means a
|
|
2251
|
+
// poll or IDLE that unwinds after this point sees that it no longer owns the flag and
|
|
2252
|
+
// leaves it alone (see claimIdling() in commands/idle.js).
|
|
2253
|
+
this._idleSession = null;
|
|
2023
2254
|
this.idling = false;
|
|
2024
2255
|
|
|
2256
|
+
// An in-flight STARTTLS upgrade has to be settled through its own single settlement
|
|
2257
|
+
// path, otherwise the upgrade promise (and the session it belongs to) stays pending
|
|
2258
|
+
// for the lifetime of the process.
|
|
2259
|
+
if (typeof this._upgradeReject === 'function') {
|
|
2260
|
+
let reject = this._upgradeReject;
|
|
2261
|
+
this._upgradeReject = null;
|
|
2262
|
+
let err = new Error('Connection closed during TLS upgrade');
|
|
2263
|
+
err.code = 'NoConnection';
|
|
2264
|
+
reject(err);
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2025
2267
|
if (typeof this.initialReject === 'function' && !this.options.verifyOnly) {
|
|
2026
2268
|
clearTimeout(this.greetingTimeout);
|
|
2027
2269
|
let reject = this.initialReject;
|
|
@@ -2045,6 +2287,20 @@ class ImapFlow extends EventEmitter {
|
|
|
2045
2287
|
this.preCheck().catch(err => this.log.warn({ err, cid: this.id }));
|
|
2046
2288
|
}
|
|
2047
2289
|
|
|
2290
|
+
// Session-only public state must not survive the connection it describes: callers read
|
|
2291
|
+
// these properties in reconnect logic and would otherwise mistake cached objects for
|
|
2292
|
+
// live server state. Cleared during the first close only, so repeated close() calls
|
|
2293
|
+
// stay idempotent and cannot emit an event twice.
|
|
2294
|
+
// `byeReason` is deliberately kept: it explains why the session ended.
|
|
2295
|
+
let closedMailbox = false;
|
|
2296
|
+
if (!this.isClosed) {
|
|
2297
|
+
closedMailbox = this.mailbox;
|
|
2298
|
+
this.mailbox = false;
|
|
2299
|
+
this.currentSelectCommand = false;
|
|
2300
|
+
this.authenticated = false;
|
|
2301
|
+
this.preCheck = false;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2048
2304
|
// Collect all pending requests to reject
|
|
2049
2305
|
let pendingRequests = [];
|
|
2050
2306
|
|
|
@@ -2156,22 +2412,16 @@ class ImapFlow extends EventEmitter {
|
|
|
2156
2412
|
if (this.isClosed) {
|
|
2157
2413
|
return;
|
|
2158
2414
|
}
|
|
2159
|
-
|
|
2160
|
-
//
|
|
2161
|
-
// writeSocket may be a PassThrough (compression) or the raw socket (no compression).
|
|
2162
|
-
// Destroy the underlying socket first, then writeSocket (if different).
|
|
2163
|
-
// The second socket.destroy() block handles the case where writeSocket.destroy()
|
|
2164
|
-
// did not also destroy the underlying socket.
|
|
2165
|
-
if (this.socket && !this.socket.destroyed && this.writeSocket !== this.socket) {
|
|
2166
|
-
try {
|
|
2167
|
-
this.socket.destroy();
|
|
2168
|
-
} catch (err) {
|
|
2169
|
-
this.log.error({ err, cid: this.id });
|
|
2170
|
-
}
|
|
2171
|
-
}
|
|
2415
|
+
// Set before teardown so a socket event that re-enters close() during destruction
|
|
2416
|
+
// cannot run this block a second time.
|
|
2172
2417
|
this.isClosed = true;
|
|
2173
2418
|
|
|
2174
|
-
|
|
2419
|
+
// Socket teardown, in one documented order. Each stream owns and reports its own
|
|
2420
|
+
// lifecycle, so each is destroyed exactly once:
|
|
2421
|
+
// 1. the compression PassThrough (writeSocket), if compression replaced it
|
|
2422
|
+
// 2. the raw socket, which is also writeSocket when compression is not active
|
|
2423
|
+
// The compression streams themselves were destroyed above.
|
|
2424
|
+
if (this.writeSocket && this.writeSocket !== this.socket && !this.writeSocket.destroyed) {
|
|
2175
2425
|
try {
|
|
2176
2426
|
this.writeSocket.destroy();
|
|
2177
2427
|
} catch (err) {
|
|
@@ -2179,7 +2429,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2179
2429
|
}
|
|
2180
2430
|
}
|
|
2181
2431
|
|
|
2182
|
-
if (this.socket && !this.socket.destroyed
|
|
2432
|
+
if (this.socket && !this.socket.destroyed) {
|
|
2183
2433
|
try {
|
|
2184
2434
|
this.socket.destroy();
|
|
2185
2435
|
} catch (err) {
|
|
@@ -2200,7 +2450,19 @@ class ImapFlow extends EventEmitter {
|
|
|
2200
2450
|
this._socketEnd = null;
|
|
2201
2451
|
this._socketTimeout = null;
|
|
2202
2452
|
|
|
2203
|
-
this.log.trace({
|
|
2453
|
+
this.log.trace({
|
|
2454
|
+
msg: 'Connection closed',
|
|
2455
|
+
cid: this.id,
|
|
2456
|
+
...(this._unknownTagCount ? { unknownTagCount: this._unknownTagCount } : {})
|
|
2457
|
+
});
|
|
2458
|
+
|
|
2459
|
+
// A mailbox that was still selected is now closed, so the transition is reported once,
|
|
2460
|
+
// whether the session ended with a clean logout or a lost transport. Emitted before
|
|
2461
|
+
// 'close' and only from the first close(), so no consumer sees it twice.
|
|
2462
|
+
if (closedMailbox) {
|
|
2463
|
+
this.emit('mailboxClose', closedMailbox);
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2204
2466
|
this.emit('close');
|
|
2205
2467
|
} catch (ex) {
|
|
2206
2468
|
// close failed
|
|
@@ -2246,6 +2508,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2246
2508
|
* @property {String} parentPath Same as `parent`, but as a complete string path (unicode string)
|
|
2247
2509
|
* @property {Set<string>} flags a set of flags for this mailbox
|
|
2248
2510
|
* @property {String} specialUse one of special-use flags (if applicable): "\All", "\Archive", "\Drafts", "\Flagged", "\Junk", "\Sent", "\Trash". Additionally INBOX has non-standard "\Inbox" flag set
|
|
2511
|
+
* @property {String} [specialUseSource] how `specialUse` was determined: `"user"` (from `specialUseHints`), `"extension"` (SPECIAL-USE or XLIST flag reported by the server) or `"name"` (matched against known localized folder names)
|
|
2249
2512
|
* @property {Boolean} listed `true` if mailbox was found from the output of LIST command
|
|
2250
2513
|
* @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers
|
|
2251
2514
|
* @property {StatusObject} [status] If `statusQuery` was used, then this value includes the status response
|
|
@@ -2268,6 +2531,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2268
2531
|
* @property {String} [specialUseHints.trash] Path to "Trash" folder
|
|
2269
2532
|
* @property {String} [specialUseHints.junk] Path to "Junk Mail" folder
|
|
2270
2533
|
* @property {String} [specialUseHints.drafts] Path to "Drafts" folder
|
|
2534
|
+
* @property {String} [specialUseHints.archive] Path to "Archive" folder
|
|
2271
2535
|
*/
|
|
2272
2536
|
|
|
2273
2537
|
/**
|
|
@@ -3752,9 +4016,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3752
4016
|
}
|
|
3753
4017
|
|
|
3754
4018
|
if (!this.socket || this.socket.destroyed) {
|
|
3755
|
-
|
|
3756
|
-
error.code = 'NoConnection';
|
|
3757
|
-
throw error;
|
|
4019
|
+
throw this.createNoConnectionError();
|
|
3758
4020
|
}
|
|
3759
4021
|
|
|
3760
4022
|
clearTimeout(this.idleStartTimer);
|
|
@@ -3763,9 +4025,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3763
4025
|
await this.preCheck();
|
|
3764
4026
|
}
|
|
3765
4027
|
|
|
3766
|
-
let
|
|
3767
|
-
|
|
3768
|
-
let result = await handler(this, ...args);
|
|
4028
|
+
let result = await this.runInternal(command, ...args);
|
|
3769
4029
|
|
|
3770
4030
|
if (command !== 'IDLE') {
|
|
3771
4031
|
// do not autostart IDLE, if IDLE itself was stopped
|
|
@@ -3775,6 +4035,33 @@ class ImapFlow extends EventEmitter {
|
|
|
3775
4035
|
return result;
|
|
3776
4036
|
}
|
|
3777
4037
|
|
|
4038
|
+
/**
|
|
4039
|
+
* Dispatches a command without the IDLE handshake that `run()` performs.
|
|
4040
|
+
*
|
|
4041
|
+
* Used by callers that already own the connection's idle state - fallback polling issues its
|
|
4042
|
+
* commands through here, because `run()` would await `preCheck()`, and the preCheck it would
|
|
4043
|
+
* await belongs to the very polling session making the call, so the session would cancel
|
|
4044
|
+
* itself. Auto-IDLE is not restarted either, for the same reason: the caller is the idle loop.
|
|
4045
|
+
*
|
|
4046
|
+
* @param {String} command Command name, as registered in the command registry.
|
|
4047
|
+
* @param {...*} args Arguments forwarded to the command implementation.
|
|
4048
|
+
* @returns {Promise<*>} Whatever the command implementation returns, or `false` for an
|
|
4049
|
+
* unknown command.
|
|
4050
|
+
*/
|
|
4051
|
+
async runInternal(command, ...args) {
|
|
4052
|
+
command = command.toUpperCase();
|
|
4053
|
+
if (!this.commands.has(command)) {
|
|
4054
|
+
return false;
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4057
|
+
if (!this.socket || this.socket.destroyed) {
|
|
4058
|
+
throw this.createNoConnectionError();
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
let handler = this.commands.get(command);
|
|
4062
|
+
return await handler(this, ...args);
|
|
4063
|
+
}
|
|
4064
|
+
|
|
3778
4065
|
// Mailbox lock queue processor. Implements a mutex pattern: only one lock
|
|
3779
4066
|
// is active at a time. When the active lock is released, the next queued
|
|
3780
4067
|
// lock is processed. The `processingLock` flag prevents concurrent runs
|
|
@@ -3832,6 +4119,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3832
4119
|
return;
|
|
3833
4120
|
}
|
|
3834
4121
|
lock.heldAt = Date.now();
|
|
4122
|
+
// Background diagnostic: must not keep the process alive on its own
|
|
3835
4123
|
lock.heldWarnTimer = setTimeout(() => {
|
|
3836
4124
|
lock.heldWarnTimer = null;
|
|
3837
4125
|
this.log.warn({
|
|
@@ -3844,6 +4132,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3844
4132
|
cid: this.id
|
|
3845
4133
|
});
|
|
3846
4134
|
}, threshold);
|
|
4135
|
+
unrefTimer(lock.heldWarnTimer);
|
|
3847
4136
|
};
|
|
3848
4137
|
|
|
3849
4138
|
// release() is captured per-lock. It must only clear this.currentLock
|
|
@@ -4199,6 +4488,11 @@ class ImapFlow extends EventEmitter {
|
|
|
4199
4488
|
/**
|
|
4200
4489
|
* Mailbox was closed
|
|
4201
4490
|
*
|
|
4491
|
+
* Emitted both when a selected mailbox is closed explicitly, by `mailboxClose()` or by
|
|
4492
|
+
* selecting a different mailbox, and when the connection itself goes away while a mailbox
|
|
4493
|
+
* was still selected, whether through a clean logout or a lost transport. The transition is
|
|
4494
|
+
* reported once per selected mailbox, before the `close` event.
|
|
4495
|
+
*
|
|
4202
4496
|
* @event module:imapflow~ImapFlow#mailboxClose
|
|
4203
4497
|
* @type {MailboxObject}
|
|
4204
4498
|
* @example
|