imapflow 1.4.9 → 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/.github/workflows/test.yml +20 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +22 -0
- package/CLAUDE.md +3 -5
- package/lib/commands/fetch.js +18 -14
- package/lib/commands/idle.js +197 -104
- package/lib/commands/list.js +19 -8
- package/lib/commands/quota.js +3 -0
- package/lib/commands/select.js +5 -0
- package/lib/commands/status.js +10 -1
- package/lib/connection-deadline.js +98 -0
- package/lib/handler/imap-compiler.js +20 -14
- package/lib/handler/imap-stream.js +141 -50
- package/lib/handler/limits.js +43 -0
- package/lib/handler/token-parser.js +38 -1
- package/lib/imap-flow.d.ts +47 -5
- package/lib/imap-flow.js +594 -283
- package/lib/proxy-connection.js +393 -98
- package/lib/special-use.js +660 -51
- package/lib/tools.js +52 -3
- package/package.json +2 -2
- package/test/commands-branches-test.js +17 -1
- package/test/commands-integration-test.js +353 -2
- package/test/connection-edge-cases-test.js +4 -40
- package/test/fixtures/fake-timers.js +115 -0
- package/test/handler-branches-test.js +4 -28
- package/test/idle-polling-test.js +349 -0
- package/test/imap-compiler-test.js +85 -0
- package/test/imap-flow-compress-test.js +12 -0
- package/test/imap-flow-coverage-test.js +3 -3
- package/test/imap-flow-fetch-download-test.js +56 -0
- 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 +182 -9
- package/test/imap-flow-server-test.js +229 -0
- package/test/imap-parser-test.js +112 -1
- package/test/imap-stream-test.js +46 -0
- package/test/integration/README.md +17 -5
- package/test/integration/rev2-live-test.js +125 -0
- package/test/integration/run-rev2-tests.sh +14 -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/search-compiler-test.js +17 -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/test/tools-test.js +42 -4
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
|
});
|
|
@@ -1541,6 +1756,9 @@ class ImapFlow extends EventEmitter {
|
|
|
1541
1756
|
return;
|
|
1542
1757
|
}
|
|
1543
1758
|
this.state = this.states.AUTHENTICATED;
|
|
1759
|
+
// documented contract for the `authenticated` property: `true` when the
|
|
1760
|
+
// connection was authenticated by a PREAUTH greeting (no credentials known)
|
|
1761
|
+
this.authenticated = true;
|
|
1544
1762
|
this.beginSession(err => {
|
|
1545
1763
|
this.log.error({ err, cid: this.id });
|
|
1546
1764
|
this.closeAfter();
|
|
@@ -1775,6 +1993,11 @@ class ImapFlow extends EventEmitter {
|
|
|
1775
1993
|
return range;
|
|
1776
1994
|
}
|
|
1777
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().
|
|
1778
2001
|
autoidle() {
|
|
1779
2002
|
clearTimeout(this.idleStartTimer);
|
|
1780
2003
|
if (this.options.disableAutoIdle || this.state !== this.states.SELECTED) {
|
|
@@ -1783,6 +2006,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1783
2006
|
this.idleStartTimer = setTimeout(() => {
|
|
1784
2007
|
this.idle().catch(err => this.log.warn({ err, cid: this.id }));
|
|
1785
2008
|
}, 15 * 1000);
|
|
2009
|
+
unrefTimer(this.idleStartTimer);
|
|
1786
2010
|
}
|
|
1787
2011
|
|
|
1788
2012
|
// PUBLIC API METHODS
|
|
@@ -1803,6 +2027,11 @@ class ImapFlow extends EventEmitter {
|
|
|
1803
2027
|
}
|
|
1804
2028
|
this._connectCalled = true;
|
|
1805
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
|
+
|
|
1806
2035
|
let connector = this.secureConnection ? tls : net;
|
|
1807
2036
|
|
|
1808
2037
|
let opts = Object.assign(
|
|
@@ -1831,11 +2060,17 @@ class ImapFlow extends EventEmitter {
|
|
|
1831
2060
|
let socket = false;
|
|
1832
2061
|
if (this.options.proxy) {
|
|
1833
2062
|
try {
|
|
1834
|
-
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 });
|
|
1835
2064
|
if (!socket) {
|
|
1836
2065
|
throw new Error('Failed to setup proxy connection');
|
|
1837
2066
|
}
|
|
1838
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
|
+
}
|
|
1839
2074
|
let error = new Error('Failed to setup proxy connection');
|
|
1840
2075
|
error.code = err.code || 'ProxyError';
|
|
1841
2076
|
error._err = err;
|
|
@@ -1845,17 +2080,13 @@ class ImapFlow extends EventEmitter {
|
|
|
1845
2080
|
}
|
|
1846
2081
|
|
|
1847
2082
|
let connectPromise = new Promise((resolve, reject) => {
|
|
2083
|
+
// Whatever the proxy phase already used is gone from the budget
|
|
1848
2084
|
this.connectTimeout = setTimeout(() => {
|
|
1849
|
-
let err =
|
|
1850
|
-
err.code = 'CONNECT_TIMEOUT';
|
|
1851
|
-
err.details = {
|
|
1852
|
-
/* c8 ignore next */ // firing the timeout with the default (large) value would hang the suite, so only the explicit-option path is tested
|
|
1853
|
-
connectionTimeout: this.options.connectionTimeout || CONNECT_TIMEOUT
|
|
1854
|
-
};
|
|
2085
|
+
let err = deadline.error();
|
|
1855
2086
|
this.log.error({ err, cid: this.id });
|
|
1856
2087
|
this.closeAfter();
|
|
1857
2088
|
reject(err);
|
|
1858
|
-
},
|
|
2089
|
+
}, deadline.remaining());
|
|
1859
2090
|
|
|
1860
2091
|
let onConnect = () => {
|
|
1861
2092
|
try {
|
|
@@ -1865,8 +2096,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1865
2096
|
// (its "before connection setup" message no longer applies).
|
|
1866
2097
|
detachEarlyErrorHandler(socket);
|
|
1867
2098
|
|
|
1868
|
-
this.socket
|
|
1869
|
-
this.socket.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT);
|
|
2099
|
+
this.configureSocket(this.socket);
|
|
1870
2100
|
|
|
1871
2101
|
this.greetingTimeout = setTimeout(() => {
|
|
1872
2102
|
let err = new Error(
|
|
@@ -2017,8 +2247,23 @@ class ImapFlow extends EventEmitter {
|
|
|
2017
2247
|
}
|
|
2018
2248
|
|
|
2019
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;
|
|
2020
2254
|
this.idling = false;
|
|
2021
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
|
+
|
|
2022
2267
|
if (typeof this.initialReject === 'function' && !this.options.verifyOnly) {
|
|
2023
2268
|
clearTimeout(this.greetingTimeout);
|
|
2024
2269
|
let reject = this.initialReject;
|
|
@@ -2042,6 +2287,20 @@ class ImapFlow extends EventEmitter {
|
|
|
2042
2287
|
this.preCheck().catch(err => this.log.warn({ err, cid: this.id }));
|
|
2043
2288
|
}
|
|
2044
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
|
+
|
|
2045
2304
|
// Collect all pending requests to reject
|
|
2046
2305
|
let pendingRequests = [];
|
|
2047
2306
|
|
|
@@ -2153,22 +2412,16 @@ class ImapFlow extends EventEmitter {
|
|
|
2153
2412
|
if (this.isClosed) {
|
|
2154
2413
|
return;
|
|
2155
2414
|
}
|
|
2156
|
-
|
|
2157
|
-
//
|
|
2158
|
-
// writeSocket may be a PassThrough (compression) or the raw socket (no compression).
|
|
2159
|
-
// Destroy the underlying socket first, then writeSocket (if different).
|
|
2160
|
-
// The second socket.destroy() block handles the case where writeSocket.destroy()
|
|
2161
|
-
// did not also destroy the underlying socket.
|
|
2162
|
-
if (this.socket && !this.socket.destroyed && this.writeSocket !== this.socket) {
|
|
2163
|
-
try {
|
|
2164
|
-
this.socket.destroy();
|
|
2165
|
-
} catch (err) {
|
|
2166
|
-
this.log.error({ err, cid: this.id });
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2415
|
+
// Set before teardown so a socket event that re-enters close() during destruction
|
|
2416
|
+
// cannot run this block a second time.
|
|
2169
2417
|
this.isClosed = true;
|
|
2170
2418
|
|
|
2171
|
-
|
|
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) {
|
|
2172
2425
|
try {
|
|
2173
2426
|
this.writeSocket.destroy();
|
|
2174
2427
|
} catch (err) {
|
|
@@ -2176,7 +2429,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2176
2429
|
}
|
|
2177
2430
|
}
|
|
2178
2431
|
|
|
2179
|
-
if (this.socket && !this.socket.destroyed
|
|
2432
|
+
if (this.socket && !this.socket.destroyed) {
|
|
2180
2433
|
try {
|
|
2181
2434
|
this.socket.destroy();
|
|
2182
2435
|
} catch (err) {
|
|
@@ -2197,7 +2450,19 @@ class ImapFlow extends EventEmitter {
|
|
|
2197
2450
|
this._socketEnd = null;
|
|
2198
2451
|
this._socketTimeout = null;
|
|
2199
2452
|
|
|
2200
|
-
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
|
+
|
|
2201
2466
|
this.emit('close');
|
|
2202
2467
|
} catch (ex) {
|
|
2203
2468
|
// close failed
|
|
@@ -2243,6 +2508,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2243
2508
|
* @property {String} parentPath Same as `parent`, but as a complete string path (unicode string)
|
|
2244
2509
|
* @property {Set<string>} flags a set of flags for this mailbox
|
|
2245
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)
|
|
2246
2512
|
* @property {Boolean} listed `true` if mailbox was found from the output of LIST command
|
|
2247
2513
|
* @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers
|
|
2248
2514
|
* @property {StatusObject} [status] If `statusQuery` was used, then this value includes the status response
|
|
@@ -2258,11 +2524,14 @@ class ImapFlow extends EventEmitter {
|
|
|
2258
2524
|
* @property {Boolean} [statusQuery.uidValidity] if `true` request mailbox `UIDVALIDITY` value
|
|
2259
2525
|
* @property {Boolean} [statusQuery.unseen] if `true` request count of unseen messages
|
|
2260
2526
|
* @property {Boolean} [statusQuery.highestModseq] if `true` request last known modseq value
|
|
2527
|
+
* @property {Boolean} [statusQuery.size] if `true` request total mailbox size in octets (requires STATUS=SIZE or IMAP4rev2)
|
|
2528
|
+
* @property {Boolean} [statusQuery.deleted] if `true` request count of messages with \\Deleted flag (requires IMAP4rev2)
|
|
2261
2529
|
* @property {Object} [specialUseHints] set specific paths as special use folders, this would override special use flags provided from the server
|
|
2262
2530
|
* @property {String} [specialUseHints.sent] Path to "Sent Mail" folder
|
|
2263
2531
|
* @property {String} [specialUseHints.trash] Path to "Trash" folder
|
|
2264
2532
|
* @property {String} [specialUseHints.junk] Path to "Junk Mail" folder
|
|
2265
2533
|
* @property {String} [specialUseHints.drafts] Path to "Drafts" folder
|
|
2534
|
+
* @property {String} [specialUseHints.archive] Path to "Archive" folder
|
|
2266
2535
|
*/
|
|
2267
2536
|
|
|
2268
2537
|
/**
|
|
@@ -2462,6 +2731,8 @@ class ImapFlow extends EventEmitter {
|
|
|
2462
2731
|
* @property {BigInt} [uidValidity] Mailbox `UIDVALIDITY` value
|
|
2463
2732
|
* @property {Number} [unseen] Count of unseen messages
|
|
2464
2733
|
* @property {BigInt} [highestModseq] Last known modseq value (if CONDSTORE extension is enabled)
|
|
2734
|
+
* @property {Number} [size] Total size of the mailbox in octets (only if requested and the server supports STATUS=SIZE or IMAP4rev2)
|
|
2735
|
+
* @property {Number} [deleted] Count of messages with \\Deleted flag (only if requested and IMAP4rev2 is active)
|
|
2465
2736
|
*/
|
|
2466
2737
|
|
|
2467
2738
|
/**
|
|
@@ -2475,6 +2746,8 @@ class ImapFlow extends EventEmitter {
|
|
|
2475
2746
|
* @param {Boolean} query.uidValidity if `true` request mailbox `UIDVALIDITY` value
|
|
2476
2747
|
* @param {Boolean} query.unseen if `true` request count of unseen messages
|
|
2477
2748
|
* @param {Boolean} query.highestModseq if `true` request last known modseq value
|
|
2749
|
+
* @param {Boolean} query.size if `true` request total mailbox size in octets (requires STATUS=SIZE or IMAP4rev2)
|
|
2750
|
+
* @param {Boolean} query.deleted if `true` request count of messages with \\Deleted flag (requires IMAP4rev2)
|
|
2478
2751
|
* @returns {Promise<StatusObject>} status of the indicated mailbox
|
|
2479
2752
|
*
|
|
2480
2753
|
* @example
|
|
@@ -2969,6 +3242,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2969
3242
|
* @property {MessageStructureObject} [bodyStructure] message body structure
|
|
2970
3243
|
* @property {Date} [internalDate] message internal date
|
|
2971
3244
|
* @property {Map<string, Buffer>} [bodyParts] a Map of message body parts where key is requested part identifier and value is a Buffer
|
|
3245
|
+
* @property {Set<string>} [binaryParts] part identifiers from `bodyParts` that arrived via FETCH BINARY, i.e. with the content-transfer-encoding already decoded by the server
|
|
2972
3246
|
* @property {Buffer} [headers] Requested header lines as Buffer
|
|
2973
3247
|
*/
|
|
2974
3248
|
|
|
@@ -3403,7 +3677,11 @@ class ImapFlow extends EventEmitter {
|
|
|
3403
3677
|
// 4. Byte limiter (enforces maxBytes cap)
|
|
3404
3678
|
// `stream` is the head of the pipeline (where raw chunks are written),
|
|
3405
3679
|
// `output` is the tail (what the caller reads from).
|
|
3406
|
-
|
|
3680
|
+
// Parts that arrived via FETCH BINARY (response.binaryParts) are already
|
|
3681
|
+
// decoded by the server - decoding again would corrupt the data, so stage 1
|
|
3682
|
+
// is skipped for them.
|
|
3683
|
+
let clientEncoding = response.binaryParts && response.binaryParts.has(part) ? false : meta.encoding;
|
|
3684
|
+
switch (clientEncoding) {
|
|
3407
3685
|
case 'base64':
|
|
3408
3686
|
output = stream = new libbase64.Decoder();
|
|
3409
3687
|
break;
|
|
@@ -3713,7 +3991,10 @@ class ImapFlow extends EventEmitter {
|
|
|
3713
3991
|
for (let part of Object.keys(data)) {
|
|
3714
3992
|
let meta = data[part].meta;
|
|
3715
3993
|
|
|
3716
|
-
|
|
3994
|
+
// parts that arrived via FETCH BINARY (response.binaryParts) are already
|
|
3995
|
+
// decoded by the server - decoding again would corrupt the data
|
|
3996
|
+
let clientEncoding = response.binaryParts && response.binaryParts.has(part) ? false : meta.encoding;
|
|
3997
|
+
switch (clientEncoding) {
|
|
3717
3998
|
case 'base64':
|
|
3718
3999
|
data[part].content = data[part].content ? libbase64.decode(data[part].content.toString()) : null;
|
|
3719
4000
|
break;
|
|
@@ -3735,9 +4016,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3735
4016
|
}
|
|
3736
4017
|
|
|
3737
4018
|
if (!this.socket || this.socket.destroyed) {
|
|
3738
|
-
|
|
3739
|
-
error.code = 'NoConnection';
|
|
3740
|
-
throw error;
|
|
4019
|
+
throw this.createNoConnectionError();
|
|
3741
4020
|
}
|
|
3742
4021
|
|
|
3743
4022
|
clearTimeout(this.idleStartTimer);
|
|
@@ -3746,9 +4025,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3746
4025
|
await this.preCheck();
|
|
3747
4026
|
}
|
|
3748
4027
|
|
|
3749
|
-
let
|
|
3750
|
-
|
|
3751
|
-
let result = await handler(this, ...args);
|
|
4028
|
+
let result = await this.runInternal(command, ...args);
|
|
3752
4029
|
|
|
3753
4030
|
if (command !== 'IDLE') {
|
|
3754
4031
|
// do not autostart IDLE, if IDLE itself was stopped
|
|
@@ -3758,6 +4035,33 @@ class ImapFlow extends EventEmitter {
|
|
|
3758
4035
|
return result;
|
|
3759
4036
|
}
|
|
3760
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
|
+
|
|
3761
4065
|
// Mailbox lock queue processor. Implements a mutex pattern: only one lock
|
|
3762
4066
|
// is active at a time. When the active lock is released, the next queued
|
|
3763
4067
|
// lock is processed. The `processingLock` flag prevents concurrent runs
|
|
@@ -3815,6 +4119,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3815
4119
|
return;
|
|
3816
4120
|
}
|
|
3817
4121
|
lock.heldAt = Date.now();
|
|
4122
|
+
// Background diagnostic: must not keep the process alive on its own
|
|
3818
4123
|
lock.heldWarnTimer = setTimeout(() => {
|
|
3819
4124
|
lock.heldWarnTimer = null;
|
|
3820
4125
|
this.log.warn({
|
|
@@ -3827,6 +4132,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3827
4132
|
cid: this.id
|
|
3828
4133
|
});
|
|
3829
4134
|
}, threshold);
|
|
4135
|
+
unrefTimer(lock.heldWarnTimer);
|
|
3830
4136
|
};
|
|
3831
4137
|
|
|
3832
4138
|
// release() is captured per-lock. It must only clear this.currentLock
|
|
@@ -4182,6 +4488,11 @@ class ImapFlow extends EventEmitter {
|
|
|
4182
4488
|
/**
|
|
4183
4489
|
* Mailbox was closed
|
|
4184
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
|
+
*
|
|
4185
4496
|
* @event module:imapflow~ImapFlow#mailboxClose
|
|
4186
4497
|
* @type {MailboxObject}
|
|
4187
4498
|
* @example
|