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.
Files changed (40) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +20 -0
  3. package/lib/commands/append.js +26 -4
  4. package/lib/commands/copyuid-parser.js +4 -2
  5. package/lib/commands/expunge.js +5 -2
  6. package/lib/commands/fetch.js +9 -3
  7. package/lib/commands/idle.js +26 -5
  8. package/lib/commands/list.js +18 -38
  9. package/lib/commands/namespace.js +2 -2
  10. package/lib/commands/quota.js +10 -2
  11. package/lib/commands/search.js +54 -14
  12. package/lib/commands/select.js +81 -70
  13. package/lib/commands/status-fields.js +68 -0
  14. package/lib/commands/status.js +23 -61
  15. package/lib/handler/imap-stream.js +56 -2
  16. package/lib/handler/limits.js +16 -4
  17. package/lib/imap-flow.d.ts +33 -2
  18. package/lib/imap-flow.js +307 -97
  19. package/lib/jp-decoder.js +30 -5
  20. package/lib/limited-passthrough.js +19 -1
  21. package/lib/tools.js +190 -39
  22. package/package.json +4 -4
  23. package/test/auto-idle-test.js +470 -0
  24. package/test/commands-branches-test.js +4 -0
  25. package/test/commands-integration-test.js +683 -0
  26. package/test/connection-edge-cases-test.js +3 -1
  27. package/test/copyuid-parser-test.js +20 -0
  28. package/test/fixtures/test-client.js +57 -0
  29. package/test/idle-polling-test.js +88 -0
  30. package/test/imap-flow-coverage-test.js +8 -12
  31. package/test/imap-flow-fetch-download-test.js +29 -10
  32. package/test/imap-flow-internals-test.js +14 -32
  33. package/test/imap-flow-methods-test.js +92 -0
  34. package/test/imap-stream-edge-cases-test.js +136 -0
  35. package/test/jp-decoder-test.js +57 -0
  36. package/test/limited-passthrough-test.js +24 -0
  37. package/test/parser-limits-test.js +18 -0
  38. package/test/reliability-improvements-test.js +3 -3
  39. package/test/timer-policy-test.js +31 -18
  40. 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
- // Tracked throttle back-off timer (see reader()). Stored so close() can clear it
334
- // and abort the wait instead of letting it keep the event loop alive for minutes.
335
- this._throttleTimer = null;
336
- this._throttleAbort = null;
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
- this.log.error({ src: 's', msg: data.payload.toString(), err, cid: this.id });
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
- if (section && section.length && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
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
- let delayResponse = throttleDelay;
1128
- if (delayResponse > 5 * 60 * 1000) {
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
- // Tracked, abortable wait. Storing the timer lets close() clear it so
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
- * When a socket timeout occurs during IDLE, the handler attempts to recover the connection
1232
- * by sending a NOOP command and then returning to IDLE state.
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 unless the current command is IDLE
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
- if (this.idling) {
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
- // Attempt to recover IDLE connections. During true IDLE the NOOP cannot
1248
- // reach the server until IDLE has been terminated: run() awaits preCheck(),
1249
- // which sends DONE and only resolves once the server has completed the IDLE
1250
- // command. Fallback polling has no such handshake - preCheck() there just
1251
- // cancels the polling session.
1252
- this.run('NOOP')
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 || !untagged.command || isNaN(untagged.command)) {
2007
+ if (!untagged) {
1898
2008
  return;
1899
2009
  }
1900
2010
 
1901
- let count = Number(untagged.command);
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 || !untagged.command || isNaN(untagged.command)) {
2040
+ if (!untagged) {
1924
2041
  return;
1925
2042
  }
1926
2043
 
1927
- let seq = Number(untagged.command);
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(entry => (typeof entry.value === 'string' ? entry.value.toUpperCase() : false)).filter(value => value);
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
- }, 15 * 1000);
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 any in-flight throttle back-off so the reader unblocks and the
2324
- // throttled request is rejected promptly rather than after the full delay.
2325
- clearTimeout(this._throttleTimer);
2326
- this._throttleTimer = null;
2327
- if (typeof this._throttleAbort === 'function') {
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
- let maxBytes = Number(options.maxBytes) || Infinity;
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
- let flowDecoder = new FlowedDecoder({
3791
- delSp: meta.delSp
3792
- });
3793
- output.on('error', err => {
3794
- flowDecoder.emit('error', err);
3795
- });
3796
- output = output.pipe(flowDecoder);
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
- output.on('error', err => {
3814
- decoder.emit('error', err);
3815
- });
3816
- output = output.pipe(decoder);
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
- output.on('error', err => {
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 (limiter.limited || fetchAborted || stream.destroyed) {
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 && !limiter.limited && !fetchAborted) {
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 7 */ // `stream` is piped to the limiter before this runs, so the head write drains synchronously and always returns true (verified for chunkSize up to 8MB); the drain-wait branch is unreachable
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
- let meta = data[part].meta;
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
- if (typeof this.preCheck === 'function') {
4116
- await this.preCheck();
4117
- }
4118
-
4119
- let result = await this.runInternal(command, ...args);
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
- if (command !== 'IDLE') {
4122
- // do not autostart IDLE, if IDLE itself was stopped
4123
- this.autoidle();
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
- this.currentLock = lock;
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
- this.currentLock = lock;
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') {