imapflow 1.6.5 → 1.7.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 +20 -0
- package/lib/commands/append.js +26 -4
- package/lib/commands/copyuid-parser.js +4 -2
- package/lib/commands/expunge.js +5 -2
- package/lib/commands/fetch.js +9 -3
- package/lib/commands/idle.js +26 -5
- package/lib/commands/list.js +18 -38
- package/lib/commands/namespace.js +2 -2
- package/lib/commands/quota.js +10 -2
- package/lib/commands/search.js +54 -14
- package/lib/commands/select.js +81 -70
- package/lib/commands/status-fields.js +68 -0
- package/lib/commands/status.js +23 -61
- package/lib/handler/imap-stream.js +56 -2
- package/lib/handler/limits.js +16 -4
- package/lib/imap-flow.d.ts +33 -2
- package/lib/imap-flow.js +307 -97
- package/lib/jp-decoder.js +30 -5
- package/lib/limited-passthrough.js +19 -1
- package/lib/tools.js +190 -39
- package/package.json +4 -4
- package/test/auto-idle-test.js +470 -0
- package/test/commands-branches-test.js +4 -0
- package/test/commands-integration-test.js +683 -0
- package/test/connection-edge-cases-test.js +3 -1
- package/test/copyuid-parser-test.js +20 -0
- package/test/fixtures/test-client.js +57 -0
- package/test/idle-polling-test.js +88 -0
- package/test/imap-flow-coverage-test.js +8 -12
- package/test/imap-flow-fetch-download-test.js +29 -10
- package/test/imap-flow-internals-test.js +14 -32
- package/test/imap-flow-methods-test.js +92 -0
- package/test/imap-stream-edge-cases-test.js +136 -0
- package/test/jp-decoder-test.js +57 -0
- package/test/limited-passthrough-test.js +24 -0
- package/test/parser-limits-test.js +18 -0
- package/test/reliability-improvements-test.js +3 -3
- package/test/timer-policy-test.js +31 -18
- package/test/tools-test.js +151 -2
package/lib/imap-flow.js
CHANGED
|
@@ -12,7 +12,7 @@ const logger = require('./logger');
|
|
|
12
12
|
const libmime = require('libmime');
|
|
13
13
|
const zlib = require('zlib');
|
|
14
14
|
const { Headers } = require('@zone-eu/mailsplit');
|
|
15
|
-
const { LimitedPassthrough } = require('./limited-passthrough');
|
|
15
|
+
const { LimitedPassthrough, normalizeByteLimit } = require('./limited-passthrough');
|
|
16
16
|
|
|
17
17
|
const { ImapStream } = require('./handler/imap-stream');
|
|
18
18
|
const { parser, compiler } = require('./handler/imap-handler');
|
|
@@ -38,7 +38,11 @@ const {
|
|
|
38
38
|
AuthenticationFailure,
|
|
39
39
|
getColorFlags,
|
|
40
40
|
hasCapability,
|
|
41
|
-
unrefTimer
|
|
41
|
+
unrefTimer,
|
|
42
|
+
parseUintValue,
|
|
43
|
+
isUnsafeKey,
|
|
44
|
+
getStringList,
|
|
45
|
+
MAX_UINT32_DIGITS
|
|
42
46
|
} = require('./tools');
|
|
43
47
|
|
|
44
48
|
const imapCommands = require('./imap-commands.js');
|
|
@@ -50,12 +54,29 @@ const UPGRADE_TIMEOUT = 10 * 1000;
|
|
|
50
54
|
|
|
51
55
|
const SOCKET_TIMEOUT = 5 * 60 * 1000;
|
|
52
56
|
|
|
57
|
+
// Ceiling for any throttle back-off wait. Both the connection-level back-off and the per-command
|
|
58
|
+
// retries derive their delay from server-supplied hints, which are unbounded.
|
|
59
|
+
const MAX_THROTTLE_DELAY = 5 * 60 * 1000;
|
|
60
|
+
|
|
53
61
|
// Default threshold for warning that a mailbox lock has been held for a long
|
|
54
62
|
// time. Intended to catch forgotten release() calls, not legitimate long ops
|
|
55
63
|
// (e.g. fetching hundreds of thousands of messages). Configurable via the
|
|
56
64
|
// ImapFlow constructor option `maxLockHoldTime`. Set to 0 or false to disable.
|
|
57
65
|
const HELD_LOCK_WARN_MS = 30 * 60 * 1000;
|
|
58
66
|
|
|
67
|
+
// How long the connection has to stay inactive before auto-IDLE starts. Long enough that a caller
|
|
68
|
+
// running a sequence of commands is not interrupted by an IDLE it immediately has to break.
|
|
69
|
+
// Configurable via the ImapFlow constructor option `autoIdleDelay`.
|
|
70
|
+
const AUTO_IDLE_DELAY = 15 * 1000;
|
|
71
|
+
|
|
72
|
+
// Headroom kept between the auto-IDLE delay and the socket inactivity watchdog, so IDLE reaches
|
|
73
|
+
// the wire before the watchdog can fire. See normalizeAutoIdleDelay().
|
|
74
|
+
const AUTO_IDLE_SOCKET_MARGIN = 1000;
|
|
75
|
+
|
|
76
|
+
// The largest delay setTimeout can honor (2^31 - 1 ms). Anything above fires after 1 ms instead,
|
|
77
|
+
// so the auto-IDLE delay cap has to stay inside this range even when socketTimeout is not.
|
|
78
|
+
const MAX_TIMER_DELAY = 2 ** 31 - 1;
|
|
79
|
+
|
|
59
80
|
const states = {
|
|
60
81
|
NOT_AUTHENTICATED: 0x01,
|
|
61
82
|
AUTHENTICATED: 0x02,
|
|
@@ -63,6 +84,49 @@ const states = {
|
|
|
63
84
|
LOGOUT: 0x04
|
|
64
85
|
};
|
|
65
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Normalizes the configured auto-IDLE delay into a value `setTimeout` can honor. Anything Node
|
|
89
|
+
* would silently turn into a 1ms timer - NaN, a negative number, a value above the 32-bit range -
|
|
90
|
+
* falls back to the default instead, because a 1ms delay means an IDLE/DONE round trip around
|
|
91
|
+
* every single command. The delay is also capped below `socketTimeout`, see AUTO_IDLE_SOCKET_MARGIN.
|
|
92
|
+
*
|
|
93
|
+
* @param {*} value - The configured `autoIdleDelay` option.
|
|
94
|
+
* @param {Number} socketTimeout - The normalized socket inactivity timeout.
|
|
95
|
+
* @param {Object} log - Logger, used to report a value that could not be used as given.
|
|
96
|
+
* @param {String} cid - Connection id for the log entry.
|
|
97
|
+
* @returns {Number} Delay in milliseconds.
|
|
98
|
+
*/
|
|
99
|
+
const normalizeAutoIdleDelay = (value, socketTimeout, log, cid) => {
|
|
100
|
+
const maxDelay = Math.max(0, Math.min(socketTimeout, MAX_TIMER_DELAY) - AUTO_IDLE_SOCKET_MARGIN);
|
|
101
|
+
const configured = value !== undefined && value !== null;
|
|
102
|
+
|
|
103
|
+
// Numeric strings are accepted, because configuration usually arrives from an environment
|
|
104
|
+
// variable or a JSON file. Booleans and blank strings are not: Number() would read them as 0,
|
|
105
|
+
// i.e. "IDLE around every command", the opposite of the "off" they suggest.
|
|
106
|
+
let delay = typeof value === 'number' || (typeof value === 'string' && value.trim()) ? Number(value) : NaN;
|
|
107
|
+
let reason = null;
|
|
108
|
+
|
|
109
|
+
if (!Number.isFinite(delay) || delay < 0) {
|
|
110
|
+
reason = 'not a non-negative finite number';
|
|
111
|
+
delay = AUTO_IDLE_DELAY;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (delay > maxDelay) {
|
|
115
|
+
// An invalid value keeps its own reason: the cap then applies to the fallback default,
|
|
116
|
+
// not to anything the caller asked for.
|
|
117
|
+
reason = reason || `above socketTimeout (${socketTimeout} ms)`;
|
|
118
|
+
delay = maxDelay;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Only an explicitly configured value is worth warning about. Capping the default because the
|
|
122
|
+
// caller picked a short socketTimeout is expected behavior, not a misconfiguration.
|
|
123
|
+
if (configured && reason) {
|
|
124
|
+
log.warn({ msg: 'Adjusted unusable autoIdleDelay option', requested: value, autoIdleDelay: delay, reason, cid });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return Math.floor(delay);
|
|
128
|
+
};
|
|
129
|
+
|
|
66
130
|
/**
|
|
67
131
|
* @typedef {Object} MailboxObject
|
|
68
132
|
* @global
|
|
@@ -180,6 +244,15 @@ class ImapFlow extends EventEmitter {
|
|
|
180
244
|
* @property {Boolean} [disableAutoIdle=false]
|
|
181
245
|
* If `true`, do not start IDLE automatically. Useful when only specific operations are needed.
|
|
182
246
|
*
|
|
247
|
+
* @property {Number} [autoIdleDelay=15000]
|
|
248
|
+
* How long (in milliseconds) the connection has to be inactive before IDLE is started automatically.
|
|
249
|
+
* Keep it above the pause your own code usually leaves between two commands, otherwise every command is
|
|
250
|
+
* followed by an IDLE that the next command has to break, costing two extra round-trips per command.
|
|
251
|
+
* To turn auto-IDLE off entirely use `disableAutoIdle` rather than a very large delay: the value is
|
|
252
|
+
* capped below `socketTimeout`, because auto-IDLE has to start before the inactivity watchdog fires.
|
|
253
|
+
* On servers without IDLE support this controls when the polling fallback starts, not how often it
|
|
254
|
+
* polls - the poll interval is `maxIdleTime`, capped at 2 minutes.
|
|
255
|
+
*
|
|
183
256
|
* @property {Object} [tls]
|
|
184
257
|
* Additional TLS options. For details, see [Node.js TLS connect](https://nodejs.org/api/tls.html#tls_tls_connect_options_callback).
|
|
185
258
|
*
|
|
@@ -323,17 +396,18 @@ class ImapFlow extends EventEmitter {
|
|
|
323
396
|
logRaw: this.logRaw,
|
|
324
397
|
secureConnection: this.secureConnection,
|
|
325
398
|
maxLineLength: this.options.maxLineLength,
|
|
326
|
-
maxLiteralSize: this.options.maxLiteralSize
|
|
399
|
+
maxLiteralSize: this.options.maxLiteralSize,
|
|
400
|
+
maxResponseSize: this.options.maxResponseSize
|
|
327
401
|
});
|
|
328
402
|
|
|
329
403
|
this.reading = false;
|
|
330
404
|
this.socket = false;
|
|
331
405
|
this.writeSocket = false;
|
|
332
406
|
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
this.
|
|
407
|
+
// In-flight throttle back-offs (see throttleWait()). Tracked as a set because more than
|
|
408
|
+
// one can be pending at a time: the reader's connection-level back-off and a command
|
|
409
|
+
// retrying its own throttled request. close() clears them all.
|
|
410
|
+
this._throttleWaits = new Set();
|
|
337
411
|
|
|
338
412
|
// Pending rejector of the in-flight STARTTLS upgrade promise (see upgradeToSTARTTLS()).
|
|
339
413
|
// Stored so emitError() can route a streamer-originated error into the upgrade's single
|
|
@@ -429,6 +503,14 @@ class ImapFlow extends EventEmitter {
|
|
|
429
503
|
this.idRequested = false;
|
|
430
504
|
|
|
431
505
|
this.maxIdleTime = this.options.maxIdleTime || false;
|
|
506
|
+
this.autoIdleDelay = normalizeAutoIdleDelay(this.options.autoIdleDelay, this.socketTimeout, this.log, this.id);
|
|
507
|
+
|
|
508
|
+
// Wall-clock time of the last fallback poll, owned by lib/commands/idle.js
|
|
509
|
+
this._lastPollAt = 0;
|
|
510
|
+
|
|
511
|
+
// Download streams still fetching chunks. Counted, not a flag, so overlapping downloads
|
|
512
|
+
// cannot clear each other's suppression of auto-IDLE.
|
|
513
|
+
this._openDownloads = 0;
|
|
432
514
|
this.missingIdleCommand = (this.options.missingIdleCommand || '').toString().toUpperCase().trim() || 'NOOP';
|
|
433
515
|
|
|
434
516
|
this.disableBinary = !!this.options.disableBinary;
|
|
@@ -816,6 +898,32 @@ class ImapFlow extends EventEmitter {
|
|
|
816
898
|
}
|
|
817
899
|
}
|
|
818
900
|
|
|
901
|
+
/**
|
|
902
|
+
* Waits out a throttle back-off.
|
|
903
|
+
*
|
|
904
|
+
* The delay is capped at MAX_THROTTLE_DELAY because it can come straight from a server hint
|
|
905
|
+
* (a Microsoft 365 "Suggested Backoff Time", say) and an uncapped hint would park the caller
|
|
906
|
+
* for weeks. The timer is unref'd and tracked so it can never outlive the client: a bare
|
|
907
|
+
* setTimeout here keeps a short-lived process alive for the full delay after close(), and
|
|
908
|
+
* leaves the caller waiting on a connection that is already gone.
|
|
909
|
+
*
|
|
910
|
+
* @param {Number} delay - Requested delay in milliseconds.
|
|
911
|
+
* @returns {Promise<Boolean>} True if close() aborted the wait, false on normal expiry.
|
|
912
|
+
*/
|
|
913
|
+
async throttleWait(delay) {
|
|
914
|
+
delay = Math.min(Math.max(Number(delay) || 0, 0), MAX_THROTTLE_DELAY);
|
|
915
|
+
|
|
916
|
+
return await new Promise(resolve => {
|
|
917
|
+
let entry = { resolve };
|
|
918
|
+
entry.timer = setTimeout(() => {
|
|
919
|
+
this._throttleWaits.delete(entry);
|
|
920
|
+
resolve(false);
|
|
921
|
+
}, delay);
|
|
922
|
+
unrefTimer(entry.timer);
|
|
923
|
+
this._throttleWaits.add(entry);
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
819
927
|
async reader() {
|
|
820
928
|
let data;
|
|
821
929
|
let processedCount = 0;
|
|
@@ -901,8 +1009,10 @@ class ImapFlow extends EventEmitter {
|
|
|
901
1009
|
try {
|
|
902
1010
|
parsed = await parser(data.payload, { literals: data.literals });
|
|
903
1011
|
} catch (err) {
|
|
904
|
-
// can not make sense of this
|
|
905
|
-
|
|
1012
|
+
// can not make sense of this. The payload can be up to the configured line
|
|
1013
|
+
// cap (1GB by default), so log only a bounded prefix: a server looping
|
|
1014
|
+
// unparseable garbage would otherwise turn this error log into a disk filler.
|
|
1015
|
+
this.log.error({ src: 's', msg: data.payload.toString('latin1', 0, 1024), payloadBytes: data.payload.length, err, cid: this.id });
|
|
906
1016
|
// An unparseable untagged line is junk that can be skipped, but the line may
|
|
907
1017
|
// have been the in-flight command's tagged completion. Dropping that one
|
|
908
1018
|
// silently strands the command: currentRequest is never cleared, so trySend()
|
|
@@ -974,7 +1084,9 @@ class ImapFlow extends EventEmitter {
|
|
|
974
1084
|
}
|
|
975
1085
|
|
|
976
1086
|
let section = parsed.attributes && parsed.attributes.length && parsed.attributes[0] && !parsed.attributes[0].value && parsed.attributes[0].section;
|
|
977
|
-
|
|
1087
|
+
// section[0] can be a parsed NIL (null), e.g. from a "[NIL]" response code - the
|
|
1088
|
+
// dereference must be guarded or one such line tears down the whole connection
|
|
1089
|
+
if (section && section.length && section[0] && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
978
1090
|
let sectionHandler = this.getSectionHandler(section[0].value.toUpperCase().trim());
|
|
979
1091
|
if (sectionHandler) {
|
|
980
1092
|
try {
|
|
@@ -1124,27 +1236,12 @@ class ImapFlow extends EventEmitter {
|
|
|
1124
1236
|
err.code = 'ETHROTTLE';
|
|
1125
1237
|
err.throttleReset = throttleDelay;
|
|
1126
1238
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
// Cap wait at 5 minutes to avoid hanging connections indefinitely.
|
|
1130
|
-
// The server-suggested delay can be very large.
|
|
1131
|
-
delayResponse = 5 * 60 * 1000;
|
|
1132
|
-
}
|
|
1239
|
+
// The server-suggested delay can be very large, so throttleWait() caps it
|
|
1240
|
+
let delayResponse = Math.min(throttleDelay, MAX_THROTTLE_DELAY);
|
|
1133
1241
|
|
|
1134
1242
|
this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err });
|
|
1135
1243
|
|
|
1136
|
-
|
|
1137
|
-
// the back-off never keeps the event loop alive, and storing the resolve
|
|
1138
|
-
// lets close() abort the wait promptly (aborted=true) instead of blocking
|
|
1139
|
-
// the reader for up to 5 minutes and rejecting long after the connection
|
|
1140
|
-
// is gone. Normal expiry resolves with aborted=false.
|
|
1141
|
-
let aborted = await new Promise(resolve => {
|
|
1142
|
-
this._throttleAbort = resolve;
|
|
1143
|
-
this._throttleTimer = setTimeout(() => resolve(false), delayResponse);
|
|
1144
|
-
unrefTimer(this._throttleTimer);
|
|
1145
|
-
});
|
|
1146
|
-
this._throttleTimer = null;
|
|
1147
|
-
this._throttleAbort = null;
|
|
1244
|
+
let aborted = await this.throttleWait(delayResponse);
|
|
1148
1245
|
|
|
1149
1246
|
if (aborted) {
|
|
1150
1247
|
// Connection closed during back-off: reject promptly with a
|
|
@@ -1228,10 +1325,22 @@ class ImapFlow extends EventEmitter {
|
|
|
1228
1325
|
/**
|
|
1229
1326
|
* Socket timeout event handler.
|
|
1230
1327
|
*
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
1328
|
+
* A quiet socket is only a dead connection when something was supposed to be talking. An
|
|
1329
|
+
* idling session, a download whose consumer stopped draining, and a held mailbox lock
|
|
1330
|
+
* whose owner is busy between commands are all expected to go quiet, so the handler keeps
|
|
1331
|
+
* such a connection alive with a NOOP instead of tearing it down. An in-flight command is
|
|
1332
|
+
* the opposite: its reply is overdue, a recovery NOOP would only queue up behind it and
|
|
1333
|
+
* never reach the wire, so the timeout is reported as an error. The IDLE command itself is
|
|
1334
|
+
* the one exception - it stays in flight for as long as idling lasts, and run() breaks it
|
|
1335
|
+
* through preCheck() before the NOOP is dispatched.
|
|
1336
|
+
*
|
|
1337
|
+
* IDLE is not restarted here: run() re-arms auto-IDLE once the NOOP settles, and
|
|
1338
|
+
* autoidle() knows whether the connection is actually free for IDLE - an open download or
|
|
1339
|
+
* a held lock keeps just the keepalive, and with disableAutoIdle nothing restarts at all.
|
|
1340
|
+
* If the server is dead the NOOP never settles, and the next timeout fires with the NOOP
|
|
1341
|
+
* as the stuck in-flight command, which lands in the error branch below.
|
|
1233
1342
|
*
|
|
1234
|
-
* @fires ImapFlow#error Emits error event
|
|
1343
|
+
* @fires ImapFlow#error Emits error event if the connection cannot be recovered
|
|
1235
1344
|
*/
|
|
1236
1345
|
this._socketTimeout =
|
|
1237
1346
|
this._socketTimeout ||
|
|
@@ -1239,26 +1348,21 @@ class ImapFlow extends EventEmitter {
|
|
|
1239
1348
|
const err = new Error('Socket timeout');
|
|
1240
1349
|
err.code = 'ETIMEOUT';
|
|
1241
1350
|
|
|
1242
|
-
|
|
1351
|
+
const quietExpected = this.idling || this._openDownloads || this.currentLock;
|
|
1352
|
+
const commandStuck = this.currentRequest && !(this.idling && this.currentRequest.command === 'IDLE');
|
|
1353
|
+
|
|
1354
|
+
if (quietExpected && !commandStuck) {
|
|
1243
1355
|
if (!this.usable || !this.socket || this.socket.destroyed) {
|
|
1244
1356
|
this.emitError(err);
|
|
1245
1357
|
return;
|
|
1246
1358
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
.then(() => this.idle())
|
|
1254
|
-
.catch(err => {
|
|
1255
|
-
this.log.warn({ msg: 'IDLE recovery failed after timeout', err, cid: this.id });
|
|
1256
|
-
if (!this.isClosed) {
|
|
1257
|
-
this.close();
|
|
1258
|
-
}
|
|
1259
|
-
});
|
|
1359
|
+
this.run('NOOP').catch(err => {
|
|
1360
|
+
this.log.warn({ msg: 'Connection recovery failed after timeout', err, cid: this.id });
|
|
1361
|
+
if (!this.isClosed) {
|
|
1362
|
+
this.close();
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1260
1365
|
} else {
|
|
1261
|
-
// Close immediately for non-IDLE operations
|
|
1262
1366
|
this.log.debug({ msg: 'Socket timeout', cid: this.id });
|
|
1263
1367
|
this.emitError(err);
|
|
1264
1368
|
}
|
|
@@ -1571,6 +1675,12 @@ class ImapFlow extends EventEmitter {
|
|
|
1571
1675
|
let opts = Object.assign(
|
|
1572
1676
|
{
|
|
1573
1677
|
socket: this.socket,
|
|
1678
|
+
// host is required even though the socket is already connected: without
|
|
1679
|
+
// it, a connection made to an IP literal (servername=false) has its
|
|
1680
|
+
// certificate verified against Node's fallback name "localhost" instead
|
|
1681
|
+
// of the IP - accepting any "localhost" certificate for any IP-hosted
|
|
1682
|
+
// server, and rejecting legitimate IP-SAN certificates.
|
|
1683
|
+
host: this.host,
|
|
1574
1684
|
servername: this.servername,
|
|
1575
1685
|
port: this.port
|
|
1576
1686
|
},
|
|
@@ -1894,11 +2004,18 @@ class ImapFlow extends EventEmitter {
|
|
|
1894
2004
|
return;
|
|
1895
2005
|
}
|
|
1896
2006
|
|
|
1897
|
-
if (!untagged
|
|
2007
|
+
if (!untagged) {
|
|
1898
2008
|
return;
|
|
1899
2009
|
}
|
|
1900
2010
|
|
|
1901
|
-
|
|
2011
|
+
// Not a usable count: anything but a bounded digit run. A digit run long enough
|
|
2012
|
+
// coerces to Infinity, which would corrupt mailbox state (resolveRange('*') would
|
|
2013
|
+
// compile to the literal "Infinity" and every range-based command would fail until
|
|
2014
|
+
// the next SELECT)
|
|
2015
|
+
let count = parseUintValue(untagged.command, MAX_UINT32_DIGITS);
|
|
2016
|
+
if (count === false) {
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
1902
2019
|
if (count === this.mailbox.exists) {
|
|
1903
2020
|
// nothing changed?
|
|
1904
2021
|
return;
|
|
@@ -1920,11 +2037,12 @@ class ImapFlow extends EventEmitter {
|
|
|
1920
2037
|
return;
|
|
1921
2038
|
}
|
|
1922
2039
|
|
|
1923
|
-
if (!untagged
|
|
2040
|
+
if (!untagged) {
|
|
1924
2041
|
return;
|
|
1925
2042
|
}
|
|
1926
2043
|
|
|
1927
|
-
|
|
2044
|
+
// Same bound untaggedExists() applies: only a bounded decimal run is a usable sequence number
|
|
2045
|
+
let seq = parseUintValue(untagged.command, MAX_UINT32_DIGITS);
|
|
1928
2046
|
if (seq && seq <= this.mailbox.exists) {
|
|
1929
2047
|
this.mailbox.exists--;
|
|
1930
2048
|
let payload = {
|
|
@@ -1955,8 +2073,14 @@ class ImapFlow extends EventEmitter {
|
|
|
1955
2073
|
let tags = [];
|
|
1956
2074
|
let uids = false;
|
|
1957
2075
|
|
|
2076
|
+
// A malformed VANISHED can carry no attributes at all, and one carrying only the
|
|
2077
|
+
// (EARLIER) tag leaves `uids` false - expandRange() handles that and yields nothing
|
|
2078
|
+
if (!untagged.attributes || !untagged.attributes.length) {
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
1958
2082
|
if (untagged.attributes.length > 1 && Array.isArray(untagged.attributes[0])) {
|
|
1959
|
-
tags = untagged.attributes[0].map(
|
|
2083
|
+
tags = getStringList(untagged.attributes[0]).map(value => value.toUpperCase());
|
|
1960
2084
|
untagged.attributes.shift();
|
|
1961
2085
|
}
|
|
1962
2086
|
|
|
@@ -2076,6 +2200,16 @@ class ImapFlow extends EventEmitter {
|
|
|
2076
2200
|
return range;
|
|
2077
2201
|
}
|
|
2078
2202
|
|
|
2203
|
+
// The single definition of "the connection is not free". A held or queued mailbox lock, a
|
|
2204
|
+
// command in flight or queued, and an open download stream all mean a caller is
|
|
2205
|
+
// mid-sequence: starting IDLE there injects an IDLE/DONE round trip - or, with
|
|
2206
|
+
// `missingIdleCommand` set to SELECT or STATUS, a mailbox poll - between two of that
|
|
2207
|
+
// caller's own commands. Every one of those states ends by calling autoidle() again, so
|
|
2208
|
+
// declining while busy postpones IDLE, it never cancels it.
|
|
2209
|
+
connectionBusy() {
|
|
2210
|
+
return !!(this.currentLock || this.locks.length || this.currentRequest || this.requestQueue.length || this._openDownloads);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2079
2213
|
// Timer process-liveness policy: connection establishment and greeting deadlines keep the
|
|
2080
2214
|
// process alive, because a caller is waiting on connect() to settle. Background timers
|
|
2081
2215
|
// (auto-IDLE, IDLE restart, fallback polling, throttle back-off, the held-lock diagnostic) are
|
|
@@ -2086,9 +2220,22 @@ class ImapFlow extends EventEmitter {
|
|
|
2086
2220
|
if (this.options.disableAutoIdle || this.state !== this.states.SELECTED) {
|
|
2087
2221
|
return;
|
|
2088
2222
|
}
|
|
2223
|
+
|
|
2224
|
+
if (this.connectionBusy()) {
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2089
2228
|
this.idleStartTimer = setTimeout(() => {
|
|
2229
|
+
// Re-checked at fire time: paths that take ownership of the connection clear this
|
|
2230
|
+
// timer, but the guard must not depend on every one of them doing so - a single
|
|
2231
|
+
// missed clearTimeout would inject IDLE between a caller's own commands. Declining
|
|
2232
|
+
// postpones rather than cancels: whatever made the connection busy calls autoidle()
|
|
2233
|
+
// again when it finishes.
|
|
2234
|
+
if (this.state !== this.states.SELECTED || this.connectionBusy()) {
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2090
2237
|
this.idle().catch(err => this.log.warn({ err, cid: this.id }));
|
|
2091
|
-
},
|
|
2238
|
+
}, this.autoIdleDelay);
|
|
2092
2239
|
unrefTimer(this.idleStartTimer);
|
|
2093
2240
|
}
|
|
2094
2241
|
|
|
@@ -2320,14 +2467,13 @@ class ImapFlow extends EventEmitter {
|
|
|
2320
2467
|
clearTimeout(this.connectTimeout);
|
|
2321
2468
|
clearTimeout(this.greetingTimeout);
|
|
2322
2469
|
|
|
2323
|
-
// Abort
|
|
2324
|
-
//
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
this._throttleAbort(true);
|
|
2329
|
-
this._throttleAbort = null;
|
|
2470
|
+
// Abort every in-flight throttle back-off so each waiter unblocks and its request is
|
|
2471
|
+
// settled promptly rather than after the full delay.
|
|
2472
|
+
for (let entry of this._throttleWaits) {
|
|
2473
|
+
clearTimeout(entry.timer);
|
|
2474
|
+
entry.resolve(true);
|
|
2330
2475
|
}
|
|
2476
|
+
this._throttleWaits.clear();
|
|
2331
2477
|
|
|
2332
2478
|
this.usable = false;
|
|
2333
2479
|
// close() takes over ownership of the idling state: dropping the session token means a
|
|
@@ -3593,7 +3739,8 @@ class ImapFlow extends EventEmitter {
|
|
|
3593
3739
|
let processed = 0;
|
|
3594
3740
|
|
|
3595
3741
|
let chunkSize = Number(options.chunkSize) || 64 * 1024;
|
|
3596
|
-
|
|
3742
|
+
// Normalized once here so every bounded stage of the pipeline below agrees on the budget
|
|
3743
|
+
let maxBytes = normalizeByteLimit(options.maxBytes);
|
|
3597
3744
|
|
|
3598
3745
|
let uid = false;
|
|
3599
3746
|
|
|
@@ -3783,24 +3930,43 @@ class ImapFlow extends EventEmitter {
|
|
|
3783
3930
|
output = stream = new PassThrough();
|
|
3784
3931
|
}
|
|
3785
3932
|
|
|
3933
|
+
// Every byte-bounded stage of the pipeline. The fetch loop below stops as soon as any of
|
|
3934
|
+
// them has taken all it will accept. The limiter at the tail is not enough on its own: a
|
|
3935
|
+
// transform in the middle that buffers its whole input before emitting anything (the
|
|
3936
|
+
// format=flowed decoder, the Japanese charset decoder) leaves the tail limiter reporting
|
|
3937
|
+
// `limited === false` however much the server sends, so a download with a small maxBytes
|
|
3938
|
+
// would still pull the entire part off the wire.
|
|
3939
|
+
let limiters = [];
|
|
3940
|
+
let isLimited = () => limiters.some(entry => entry.limited);
|
|
3941
|
+
|
|
3942
|
+
// Appending a stage means forwarding the current tail's errors to it before piping, so a
|
|
3943
|
+
// failure anywhere reaches the stream the caller is reading
|
|
3944
|
+
let pipeStage = stage => {
|
|
3945
|
+
output.on('error', err => {
|
|
3946
|
+
stage.emit('error', err);
|
|
3947
|
+
});
|
|
3948
|
+
output = output.pipe(stage);
|
|
3949
|
+
return stage;
|
|
3950
|
+
};
|
|
3951
|
+
|
|
3786
3952
|
let isTextNode = ['text/html', 'text/plain', 'text/x-amp-html'].includes(meta.contentType) || (part === '1' && !meta.contentType);
|
|
3787
3953
|
if ((!meta.disposition || meta.disposition === 'inline') && isTextNode) {
|
|
3788
3954
|
// RFC 3676 format=flowed text: unwrap soft line breaks
|
|
3789
3955
|
if (meta.flowed) {
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3956
|
+
// FlowedDecoder buffers its whole input before emitting, and being third party it
|
|
3957
|
+
// carries no bound of its own, so bound what it can ever be handed. Unwrapping only
|
|
3958
|
+
// removes bytes, so capping its input at maxBytes cannot push the delivered output
|
|
3959
|
+
// above the cap either.
|
|
3960
|
+
limiters.push(pipeStage(new LimitedPassthrough({ maxBytes })));
|
|
3961
|
+
|
|
3962
|
+
pipeStage(new FlowedDecoder({ delSp: meta.delSp }));
|
|
3797
3963
|
}
|
|
3798
3964
|
|
|
3799
3965
|
// Convert non-UTF-8 charsets to UTF-8 via a streaming decoder.
|
|
3800
3966
|
// ASCII and UTF-8 need no conversion. Unknown charsets are left as-is.
|
|
3801
3967
|
if (meta.charset && !['ascii', 'usascii', 'utf8'].includes(meta.charset.toLowerCase().replace(/[^a-z0-9]+/g, ''))) {
|
|
3802
3968
|
try {
|
|
3803
|
-
let decoder = getDecoder(meta.charset);
|
|
3969
|
+
let decoder = getDecoder(meta.charset, maxBytes);
|
|
3804
3970
|
// Safety listener attached first so the decoder always has at least
|
|
3805
3971
|
// one 'error' listener. Prevents Node.js from throwing
|
|
3806
3972
|
// ERR_UNHANDLED_ERROR if a later pipe setup step throws and leaves
|
|
@@ -3810,10 +3976,10 @@ class ImapFlow extends EventEmitter {
|
|
|
3810
3976
|
decoder.on('error', err => {
|
|
3811
3977
|
this.log.warn({ err, charset: meta.charset, cid: this.id });
|
|
3812
3978
|
});
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3979
|
+
// The Japanese decoder buffers its whole input as well, and reports the same
|
|
3980
|
+
// `limited` flag the limiters do so the fetch loop can stop once it is full.
|
|
3981
|
+
// A streaming decoder has no such flag, which reads as false and is correct.
|
|
3982
|
+
limiters.push(pipeStage(decoder));
|
|
3817
3983
|
// force to utf-8 for output
|
|
3818
3984
|
meta.charset = 'utf-8';
|
|
3819
3985
|
} catch {
|
|
@@ -3822,11 +3988,8 @@ class ImapFlow extends EventEmitter {
|
|
|
3822
3988
|
}
|
|
3823
3989
|
}
|
|
3824
3990
|
|
|
3825
|
-
let limiter = new LimitedPassthrough({ maxBytes });
|
|
3826
|
-
|
|
3827
|
-
limiter.emit('error', err);
|
|
3828
|
-
});
|
|
3829
|
-
output = output.pipe(limiter);
|
|
3991
|
+
let limiter = pipeStage(new LimitedPassthrough({ maxBytes }));
|
|
3992
|
+
limiters.push(limiter);
|
|
3830
3993
|
|
|
3831
3994
|
// Cleanup function
|
|
3832
3995
|
const cleanup = () => {
|
|
@@ -3841,7 +4004,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3841
4004
|
output.once('close', cleanup);
|
|
3842
4005
|
|
|
3843
4006
|
let writeChunk = chunk => {
|
|
3844
|
-
if (
|
|
4007
|
+
if (isLimited() || fetchAborted || stream.destroyed) {
|
|
3845
4008
|
return true;
|
|
3846
4009
|
}
|
|
3847
4010
|
return stream.write(chunk);
|
|
@@ -3851,7 +4014,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3851
4014
|
// Stops when the server returns a short chunk (< chunkSize), the byte
|
|
3852
4015
|
// limiter is satisfied, or the consumer destroys the output stream.
|
|
3853
4016
|
let fetchAllParts = async () => {
|
|
3854
|
-
while (hasMore && !
|
|
4017
|
+
while (hasMore && !isLimited() && !fetchAborted) {
|
|
3855
4018
|
let { chunk } = await getNextPart();
|
|
3856
4019
|
if (!chunk || fetchAborted) {
|
|
3857
4020
|
break;
|
|
@@ -3903,6 +4066,23 @@ class ImapFlow extends EventEmitter {
|
|
|
3903
4066
|
}
|
|
3904
4067
|
};
|
|
3905
4068
|
|
|
4069
|
+
// A download is a sequence of chunk FETCHes with a backpressure wait in between. Those
|
|
4070
|
+
// gaps look exactly like an inactive connection, so without this auto-IDLE would start
|
|
4071
|
+
// between chunks and the next chunk would have to break it again - two extra round
|
|
4072
|
+
// trips per chunk, for as long as the consumer is slow. Counted before control returns
|
|
4073
|
+
// to the event loop: the head chunk's own FETCH already armed the auto-IDLE timer, and
|
|
4074
|
+
// with a very short autoIdleDelay that timer could otherwise fire before the deferred
|
|
4075
|
+
// chunk loop below has marked the download open.
|
|
4076
|
+
this._openDownloads++;
|
|
4077
|
+
let downloadDone = false;
|
|
4078
|
+
let finishDownload = () => {
|
|
4079
|
+
if (!downloadDone) {
|
|
4080
|
+
downloadDone = true;
|
|
4081
|
+
this._openDownloads--;
|
|
4082
|
+
this.autoidle();
|
|
4083
|
+
}
|
|
4084
|
+
};
|
|
4085
|
+
|
|
3906
4086
|
// Kick off the download pipeline asynchronously. The first chunk was
|
|
3907
4087
|
// already fetched above (to get metadata); write it to the decoder
|
|
3908
4088
|
// stream and then fetch remaining chunks via fetchAllParts().
|
|
@@ -3927,6 +4107,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3927
4107
|
/* c8 ignore stop */
|
|
3928
4108
|
})
|
|
3929
4109
|
.finally(() => {
|
|
4110
|
+
finishDownload();
|
|
3930
4111
|
if (!fetchAborted && stream && !stream.destroyed) {
|
|
3931
4112
|
stream.end();
|
|
3932
4113
|
}
|
|
@@ -3939,6 +4120,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3939
4120
|
writeResult = writeChunk(chunk);
|
|
3940
4121
|
} catch (err) {
|
|
3941
4122
|
stream.emit('error', err);
|
|
4123
|
+
finishDownload();
|
|
3942
4124
|
/* c8 ignore next 3 */ // emitting the error above triggers cleanup (fetchAborted=true), so this end() guard is already false here
|
|
3943
4125
|
if (!fetchAborted && stream && !stream.destroyed) {
|
|
3944
4126
|
stream.end();
|
|
@@ -3946,12 +4128,14 @@ class ImapFlow extends EventEmitter {
|
|
|
3946
4128
|
return;
|
|
3947
4129
|
}
|
|
3948
4130
|
|
|
3949
|
-
/* c8 ignore next
|
|
4131
|
+
/* c8 ignore next 9 */ // `stream` is piped to the limiter before this runs, so the head write drains synchronously and always returns true (verified for chunkSize up to 8MB); the drain-wait branch is unreachable
|
|
3950
4132
|
if (!writeResult) {
|
|
3951
4133
|
// Initial chunk filled the buffer, wait for drain
|
|
3952
4134
|
stream.once('drain', () => {
|
|
3953
4135
|
if (!fetchAborted) {
|
|
3954
4136
|
runFetchAllParts();
|
|
4137
|
+
} else {
|
|
4138
|
+
finishDownload();
|
|
3955
4139
|
}
|
|
3956
4140
|
});
|
|
3957
4141
|
} else {
|
|
@@ -4012,6 +4196,12 @@ class ImapFlow extends EventEmitter {
|
|
|
4012
4196
|
|
|
4013
4197
|
for (let [part, content] of response.bodyParts) {
|
|
4014
4198
|
let keyParts = part.split('.mime');
|
|
4199
|
+
// The server chooses the BODY[...] keys it answers with: never let one be a
|
|
4200
|
+
// prototype-chain name, or the assignments below write onto Object.prototype
|
|
4201
|
+
// (process-wide pollution) instead of the result object.
|
|
4202
|
+
if (isUnsafeKey(keyParts[0])) {
|
|
4203
|
+
continue;
|
|
4204
|
+
}
|
|
4015
4205
|
if (keyParts.length === 1) {
|
|
4016
4206
|
// content
|
|
4017
4207
|
let key = keyParts[0];
|
|
@@ -4080,7 +4270,11 @@ class ImapFlow extends EventEmitter {
|
|
|
4080
4270
|
}
|
|
4081
4271
|
|
|
4082
4272
|
for (let part of Object.keys(data)) {
|
|
4083
|
-
|
|
4273
|
+
// `meta` is only built from the companion BODY[<part>.MIME] item. A server may
|
|
4274
|
+
// legally answer with fewer items than were requested, and one part arriving
|
|
4275
|
+
// without its MIME headers must not cost the caller the whole download.
|
|
4276
|
+
let meta = data[part].meta || {};
|
|
4277
|
+
data[part].meta = meta;
|
|
4084
4278
|
|
|
4085
4279
|
// parts that arrived via FETCH BINARY (response.binaryParts) are already
|
|
4086
4280
|
// decoded by the server - decoding again would corrupt the data
|
|
@@ -4112,18 +4306,22 @@ class ImapFlow extends EventEmitter {
|
|
|
4112
4306
|
|
|
4113
4307
|
clearTimeout(this.idleStartTimer);
|
|
4114
4308
|
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4309
|
+
try {
|
|
4310
|
+
// The preCheck (breaking an active IDLE) sits inside the try on purpose: the
|
|
4311
|
+
// clearTimeout above is unconditional, so every exit - a failed command or a
|
|
4312
|
+
// preCheck that rejects - must still reach the finally, or auto-IDLE would stay
|
|
4313
|
+
// disarmed on an otherwise healthy connection until some later command succeeded.
|
|
4314
|
+
if (typeof this.preCheck === 'function') {
|
|
4315
|
+
await this.preCheck();
|
|
4316
|
+
}
|
|
4120
4317
|
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4318
|
+
return await this.runInternal(command, ...args);
|
|
4319
|
+
} finally {
|
|
4320
|
+
if (command !== 'IDLE') {
|
|
4321
|
+
// do not autostart IDLE, if IDLE itself was stopped
|
|
4322
|
+
this.autoidle();
|
|
4323
|
+
}
|
|
4124
4324
|
}
|
|
4125
|
-
|
|
4126
|
-
return result;
|
|
4127
4325
|
}
|
|
4128
4326
|
|
|
4129
4327
|
/**
|
|
@@ -4244,6 +4442,10 @@ class ImapFlow extends EventEmitter {
|
|
|
4244
4442
|
idling: this.idling
|
|
4245
4443
|
});
|
|
4246
4444
|
this.currentLock = false;
|
|
4445
|
+
// autoidle() will not arm while a lock is held, so the release is what
|
|
4446
|
+
// restarts it. It re-checks the queue itself, so a lock waiting behind
|
|
4447
|
+
// this one still keeps IDLE off.
|
|
4448
|
+
this.autoidle();
|
|
4247
4449
|
// Use setImmediate to avoid stack overflow
|
|
4248
4450
|
setImmediate(() => {
|
|
4249
4451
|
this.processLocks().catch(err => this.log.error({ err, cid: this.id }));
|
|
@@ -4265,6 +4467,18 @@ class ImapFlow extends EventEmitter {
|
|
|
4265
4467
|
continue; // Process next lock in queue
|
|
4266
4468
|
}
|
|
4267
4469
|
|
|
4470
|
+
// Both grant paths finish the same way. autoidle() is re-checked because a stale
|
|
4471
|
+
// auto-IDLE timer may still be armed at this point: on the SELECT path run()
|
|
4472
|
+
// re-arms auto-IDLE when the SELECT settles - a moment before currentLock is set -
|
|
4473
|
+
// and the fast path can inherit a timer from an earlier command. Either way the
|
|
4474
|
+
// timer must not fire inside the lock.
|
|
4475
|
+
const grantLock = () => {
|
|
4476
|
+
this.currentLock = lock;
|
|
4477
|
+
armHeldTimer();
|
|
4478
|
+
this.autoidle();
|
|
4479
|
+
resolve({ path, release });
|
|
4480
|
+
};
|
|
4481
|
+
|
|
4268
4482
|
if (this.mailbox && this.mailbox.path === path && !!this.mailbox.readOnly === !!options.readOnly) {
|
|
4269
4483
|
// Fast path: mailbox is already selected with the right access mode
|
|
4270
4484
|
this.log.trace({
|
|
@@ -4274,9 +4488,7 @@ class ImapFlow extends EventEmitter {
|
|
|
4274
4488
|
idling: this.idling,
|
|
4275
4489
|
...(options.description && { description: options.description })
|
|
4276
4490
|
});
|
|
4277
|
-
|
|
4278
|
-
armHeldTimer();
|
|
4279
|
-
resolve({ path, release });
|
|
4491
|
+
grantLock();
|
|
4280
4492
|
break; // Stop processing; next lock waits for release()
|
|
4281
4493
|
}
|
|
4282
4494
|
|
|
@@ -4290,9 +4502,7 @@ class ImapFlow extends EventEmitter {
|
|
|
4290
4502
|
idling: this.idling,
|
|
4291
4503
|
...(options.description && { description: options.description })
|
|
4292
4504
|
});
|
|
4293
|
-
|
|
4294
|
-
armHeldTimer();
|
|
4295
|
-
resolve({ path, release });
|
|
4505
|
+
grantLock();
|
|
4296
4506
|
break; // Wait for this lock to be released
|
|
4297
4507
|
} catch (err) {
|
|
4298
4508
|
if (err.responseStatus === 'NO') {
|