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
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.6.5"
2
+ ".": "1.7.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.0](https://github.com/postalsys/imapflow/compare/v1.6.6...v1.7.0) (2026-08-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * **idle:** make the auto-IDLE delay configurable ([311fe0c](https://github.com/postalsys/imapflow/commit/311fe0ccb0d75d4ddbe797bf5bc755df5bf0485f))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **idle:** align the socket watchdog with the auto-IDLE busy guard ([d5e7191](https://github.com/postalsys/imapflow/commit/d5e71915ac7a1829b7e1340da6195a5e3e9984b2))
14
+ * **idle:** validate autoIdleDelay and keep auto-IDLE off a busy connection ([aeafdf6](https://github.com/postalsys/imapflow/commit/aeafdf62fab3b29cd488d9b8cbdffc8896107d6e))
15
+
16
+ ## [1.6.6](https://github.com/postalsys/imapflow/compare/v1.6.5...v1.6.6) (2026-08-07)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * harden parsing of untrusted server response values ([1560424](https://github.com/postalsys/imapflow/commit/15604240b5fe5f7ec57514db17f41123140b53fe))
22
+
3
23
  ## [1.6.5](https://github.com/postalsys/imapflow/compare/v1.6.4...v1.6.5) (2026-07-29)
4
24
 
5
25
 
@@ -1,6 +1,17 @@
1
1
  'use strict';
2
2
 
3
- const { formatFlag, canUseFlag, formatDateTime, normalizePath, encodePath, comparePaths, enhanceCommandError } = require('../tools.js');
3
+ const {
4
+ formatFlag,
5
+ canUseFlag,
6
+ formatDateTime,
7
+ normalizePath,
8
+ encodePath,
9
+ comparePaths,
10
+ enhanceCommandError,
11
+ parseBigIntValue,
12
+ parseUintValue,
13
+ MAX_UINT32_DIGITS
14
+ } = require('../tools.js');
4
15
 
5
16
  /**
6
17
  * Appends a message to a mailbox.
@@ -79,7 +90,15 @@ module.exports = async (connection, destination, content, flags, idate) => {
79
90
  // Handler for untagged EXISTS: captures the new message count which gives
80
91
  // us the sequence number of the appended message (it's the latest message).
81
92
  const handleExistsUpdate = untagged => {
82
- map.seq = Number(untagged.command);
93
+ // The count is written into the live mailbox state below, so it has to clear the
94
+ // same bar untaggedExists() applies: a digit run long enough to coerce to Infinity
95
+ // would make resolveRange('*') compile to the literal string "Infinity" and break
96
+ // every range-based command until the next SELECT.
97
+ let seq = parseUintValue(untagged.command, MAX_UINT32_DIGITS);
98
+ if (seq === false) {
99
+ return;
100
+ }
101
+ map.seq = seq;
83
102
 
84
103
  // Update the connection's mailbox state and emit 'exists' event if the
85
104
  // count changed (notifies listeners about the new message).
@@ -109,8 +128,11 @@ module.exports = async (connection, destination, content, flags, idate) => {
109
128
  if (section && section.length) {
110
129
  let responseCode = section[0] && typeof section[0].value === 'string' ? section[0].value : '';
111
130
  if (responseCode.toUpperCase() === 'APPENDUID') {
112
- let uidValidity = section[1] && typeof section[1].value === 'string' && !isNaN(section[1].value) ? BigInt(section[1].value) : false;
113
- let uid = section[2] && typeof section[2].value === 'string' && !isNaN(section[2].value) ? Number(section[2].value) : false;
131
+ // Bounded digit runs only: isNaN() also passes '1e5', which BigInt() rejects
132
+ // with a throw - and this catch rethrows, so the append would reject after the
133
+ // message was already stored and a retrying caller would duplicate it.
134
+ let uidValidity = parseBigIntValue(section[1] && section[1].value, MAX_UINT32_DIGITS);
135
+ let uid = parseUintValue(section[2] && section[2].value, MAX_UINT32_DIGITS);
114
136
  if (uidValidity !== false) {
115
137
  map.uidValidity = uidValidity;
116
138
  }
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { expandRange } = require('../tools.js');
3
+ const { expandRange, parseBigIntValue } = require('../tools.js');
4
4
 
5
5
  /**
6
6
  * Parses COPYUID response code from an IMAP response (RFC 4315).
@@ -18,7 +18,9 @@ function parseCopyUid(response, map) {
18
18
  return;
19
19
  }
20
20
 
21
- let uidValidity = section[1] && typeof section[1].value === 'string' && !isNaN(section[1].value) ? BigInt(section[1].value) : false;
21
+ // Only a bounded pure digit string is accepted: isNaN() also passes values like "1e5" or
22
+ // "Infinity", which BigInt() then rejects with a throw that loses the uidMap.
23
+ let uidValidity = parseBigIntValue(section[1] && section[1].value);
22
24
  if (uidValidity !== false) {
23
25
  map.uidValidity = uidValidity;
24
26
  }
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { enhanceCommandError, hasCapability } = require('../tools.js');
3
+ const { enhanceCommandError, hasCapability, parseBigIntValue } = require('../tools.js');
4
4
 
5
5
  /**
6
6
  * Deletes specified messages by flagging them as Deleted and expunging.
@@ -41,7 +41,10 @@ module.exports = async (connection, range, options) => {
41
41
  let section = response.response.attributes && response.response.attributes[0] && response.response.attributes[0].section;
42
42
  let responseCode = section && section.length && section[0] && typeof section[0].value === 'string' ? section[0].value : '';
43
43
  if (responseCode.toUpperCase() === 'HIGHESTMODSEQ') {
44
- let highestModseq = section[1] && typeof section[1].value === 'string' && !isNaN(section[1].value) ? BigInt(section[1].value) : false;
44
+ // Bounded digit runs only: isNaN() also passes '1e5', which BigInt() rejects with
45
+ // a throw that the catch below would swallow, making messageDelete() report false
46
+ // even though the server expunged the messages.
47
+ let highestModseq = parseBigIntValue(section[1] && section[1].value);
45
48
  if (highestModseq && (!connection.mailbox.highestModseq || highestModseq > connection.mailbox.highestModseq)) {
46
49
  connection.mailbox.highestModseq = highestModseq;
47
50
  }
@@ -235,7 +235,9 @@ module.exports = async (connection, range, query, options) => {
235
235
  // If server provides a throttleReset hint, use that if longer.
236
236
  const backoffDelay = Math.min(baseDelay * Math.pow(2, retryCount), 30000); // Cap at 30 seconds
237
237
 
238
- // Use throttle reset time if provided and longer than backoff
238
+ // Use throttle reset time if provided and longer than backoff. The hint is
239
+ // server-controlled, so the wait goes through connection.throttleWait(), which caps
240
+ // it and keeps the timer tracked and abortable.
239
241
  const delay = err.throttleReset && err.throttleReset > backoffDelay ? err.throttleReset : backoffDelay;
240
242
 
241
243
  connection.log.warn({
@@ -248,8 +250,12 @@ module.exports = async (connection, range, query, options) => {
248
250
  delayMs: delay
249
251
  });
250
252
 
251
- // Wait before retrying
252
- await new Promise(resolve => setTimeout(resolve, delay));
253
+ // An aborted wait means the client was closed, so give up rather than reissuing
254
+ // the FETCH on a connection that is already gone.
255
+ let aborted = await connection.throttleWait(delay);
256
+ if (aborted) {
257
+ throw connection.createNoConnectionError(connection.byeReason);
258
+ }
253
259
 
254
260
  retryCount++;
255
261
  continue;
@@ -257,14 +257,16 @@ async function runPollingFallback(connection, maxIdleTime) {
257
257
 
258
258
  pollOnce(connection, session)
259
259
  .then(() => {
260
+ // Stamped only after a poll actually completed: a failed poll must not
261
+ // satisfy the resumed schedule below, or the next session would defer
262
+ // its first poll a full interval past an attempt that checked nothing.
263
+ connection._lastPollAt = Date.now();
260
264
  // Cancellation is re-checked here: the session may have been broken while
261
265
  // this poll was in flight, and an orphaned poller must not schedule again.
262
266
  if (session.cancelled) {
263
267
  return;
264
268
  }
265
- session.timer = setTimeout(runPoll, interval);
266
- // Background polling must not keep the process alive
267
- unrefTimer(session.timer);
269
+ scheduleNextPoll(interval);
268
270
  })
269
271
  .catch(err => {
270
272
  connection.log.warn({ err, cid: connection.id });
@@ -272,9 +274,28 @@ async function runPollingFallback(connection, maxIdleTime) {
272
274
  });
273
275
  };
274
276
 
277
+ function scheduleNextPoll(delay) {
278
+ session.timer = setTimeout(runPoll, delay);
279
+ // Background polling must not keep the process alive
280
+ unrefTimer(session.timer);
281
+ }
282
+
275
283
  connection.log.debug({ src: 'c', msg: `initiated NOOP loop`, cid: connection.id });
276
- // Keep the immediate first poll
277
- runPoll();
284
+
285
+ // Every auto-IDLE restart begins a fresh polling session, so an unconditional first
286
+ // poll would tie the poll rate to how often the caller runs commands rather than to
287
+ // `interval`: with a short autoIdleDelay, a command every few seconds turns into a
288
+ // poll every few seconds. The last poll timestamp lives on the connection, so a new
289
+ // session resumes the previous one's schedule instead of restarting it.
290
+ // Clamped at zero because a backward wall-clock step (NTP, VM resume) leaves the
291
+ // stamp in the future; however large the jump, the next poll must never be more
292
+ // than one full interval away.
293
+ let sinceLastPoll = Math.max(0, Date.now() - (connection._lastPollAt || 0));
294
+ if (sinceLastPoll >= interval) {
295
+ runPoll();
296
+ } else {
297
+ scheduleNextPoll(interval - sinceLastPoll);
298
+ }
278
299
  });
279
300
  } finally {
280
301
  session.cancelled = true;
@@ -1,6 +1,16 @@
1
1
  'use strict';
2
2
 
3
- const { decodePath, encodePath, normalizePath, enhanceCommandError, hasCapability, isRev2Active, buildStatusQueryAttributes } = require('../tools.js');
3
+ const {
4
+ decodePath,
5
+ encodePath,
6
+ normalizePath,
7
+ enhanceCommandError,
8
+ hasCapability,
9
+ isRev2Active,
10
+ buildStatusQueryAttributes,
11
+ getStringList
12
+ } = require('../tools.js');
13
+ const { parseStatusList } = require('./status-fields.js');
4
14
  const { specialUse } = require('../special-use');
5
15
 
6
16
  /**
@@ -133,7 +143,9 @@ module.exports = async (connection, reference, mailbox, options) => {
133
143
  // User-provided hints map mailbox paths to special-use types (e.g., {sent: "Sent Items"}).
134
144
  // These override server-reported flags and name-based guesses. Converted to a
135
145
  // path-keyed lookup: { "Sent Items" => "\\Sent" }
136
- let specialUseHints = {};
146
+ // Keyed by server-supplied mailbox paths further down, so a null prototype keeps a
147
+ // path like "constructor" from resolving to an inherited member on lookup
148
+ let specialUseHints = Object.create(null);
137
149
  if (options.specialUseHints && typeof options.specialUseHints === 'object') {
138
150
  for (let type of Object.keys(options.specialUseHints)) {
139
151
  if (
@@ -170,7 +182,7 @@ module.exports = async (connection, reference, mailbox, options) => {
170
182
  // Decode from modified UTF-7 wire format and normalize the path
171
183
  path: normalizePath(connection, decodePath(connection, (untagged.attributes[2] && untagged.attributes[2].value) || '')),
172
184
  pathAsListed: (untagged.attributes[2] && untagged.attributes[2].value) || '',
173
- flags: new Set(untagged.attributes[0].map(entry => entry.value)),
185
+ flags: new Set(getStringList(untagged.attributes[0])),
174
186
  delimiter: untagged.attributes[1] && untagged.attributes[1].value,
175
187
  listed: true
176
188
  };
@@ -236,41 +248,9 @@ module.exports = async (connection, reference, mailbox, options) => {
236
248
  return;
237
249
  }
238
250
 
239
- const STATUS_FIELD_MAP = {
240
- MESSAGES: { key: 'messages', parser: Number },
241
- RECENT: { key: 'recent', parser: Number },
242
- UIDNEXT: { key: 'uidNext', parser: Number },
243
- UIDVALIDITY: { key: 'uidValidity', parser: BigInt },
244
- UNSEEN: { key: 'unseen', parser: Number },
245
- HIGHESTMODSEQ: { key: 'highestModseq', parser: BigInt },
246
- // IMAP4rev2 additions (RFC 9051): mailbox size and \Deleted count
247
- SIZE: { key: 'size', parser: Number },
248
- DELETED: { key: 'deleted', parser: Number }
249
- };
250
-
251
- let key;
252
251
  let map = { path: statusPath };
253
-
254
- statusList.forEach((entry, i) => {
255
- if (i % 2 === 0) {
256
- key = entry && typeof entry.value === 'string' ? entry.value : false;
257
- return;
258
- }
259
- if (!key || !entry || typeof entry.value !== 'string') {
260
- return;
261
- }
262
-
263
- const fieldConfig = STATUS_FIELD_MAP[key.toUpperCase()];
264
- if (!fieldConfig) {
265
- return;
266
- }
267
-
268
- const value = !isNaN(entry.value) ? fieldConfig.parser(entry.value) : false;
269
- if (value === false) {
270
- return;
271
- }
272
-
273
- map[fieldConfig.key] = value;
252
+ parseStatusList(statusList, (key, value) => {
253
+ map[key] = value;
274
254
  });
275
255
 
276
256
  statusMap.set(statusPath, map);
@@ -455,7 +435,7 @@ module.exports = async (connection, reference, mailbox, options) => {
455
435
  let entry = {
456
436
  path: normalizePath(connection, decodePath(connection, (untagged.attributes[2] && untagged.attributes[2].value) || '')),
457
437
  pathAsListed: (untagged.attributes[2] && untagged.attributes[2].value) || '',
458
- flags: new Set(untagged.attributes[0].map(entry => entry.value)),
438
+ flags: new Set(getStringList(untagged.attributes[0])),
459
439
  delimiter: untagged.attributes[1] && untagged.attributes[1].value,
460
440
  subscribed: true
461
441
  };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { hasCapability } = require('../tools.js');
3
+ const { hasCapability, getStringList } = require('../tools.js');
4
4
 
5
5
  /**
6
6
  * Requests NAMESPACE info from the server.
@@ -95,7 +95,7 @@ async function getListPrefix(connection) {
95
95
  return;
96
96
  }
97
97
 
98
- map.flags = new Set(untagged.attributes[0].map(entry => entry.value));
98
+ map.flags = new Set(getStringList(untagged.attributes[0]));
99
99
  map.delimiter = untagged.attributes[1] && untagged.attributes[1].value;
100
100
  map.prefix = (untagged.attributes[2] && untagged.attributes[2].value) || '';
101
101
  if (map.delimiter && map.prefix.charAt(0) === map.delimiter) {
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
3
+ const { encodePath, normalizePath, enhanceCommandError, parseUintValue, isUnsafeKey } = require('../tools.js');
4
4
 
5
5
  /**
6
6
  * Requests quota information for a mailbox.
@@ -47,11 +47,19 @@ module.exports = async (connection, path) => {
47
47
  return;
48
48
  }
49
49
 
50
- let value = attribute && typeof attribute.value === 'string' && !isNaN(attribute.value) ? Number(attribute.value) : false;
50
+ // isNaN() also passes '1e5', ' 12 ' and 'Infinity', none of which is a usable octet count
51
+ let value = parseUintValue(attribute && attribute.value);
51
52
  if (value === false) {
52
53
  return;
53
54
  }
54
55
 
56
+ // Resource names are server-controlled. This object is returned to the caller, so it
57
+ // keeps a normal prototype and unsafe names are dropped instead; the fixed fields
58
+ // must keep their values too.
59
+ if (isUnsafeKey(key) || key === 'path' || key === 'quotaroot') {
60
+ return;
61
+ }
62
+
55
63
  if (!map[key]) {
56
64
  map[key] = {};
57
65
  }
@@ -1,6 +1,14 @@
1
1
  'use strict';
2
2
 
3
- const { enhanceCommandError, hasCapability, isValidSequenceValue } = require('../tools.js');
3
+ const {
4
+ enhanceCommandError,
5
+ hasCapability,
6
+ isValidSequenceValue,
7
+ parseBigIntValue,
8
+ parseUintValue,
9
+ MAX_UINT32_DIGITS,
10
+ EXPANDED_RANGE_LIMIT
11
+ } = require('../tools.js');
4
12
  const { searchCompiler } = require('../search-compiler.js');
5
13
 
6
14
  /**
@@ -48,27 +56,29 @@ function parseEsearchResponse(attrs) {
48
56
  continue;
49
57
  }
50
58
  switch (key) {
59
+ // COUNT is a plain message count; MIN and MAX are sequence numbers or UIDs. All
60
+ // three are bounded decimal runs - isNaN() would also admit '1e400' (Infinity)
51
61
  case 'COUNT': {
52
- const n = Number(attrs[++i]?.value);
53
- if (!isNaN(n)) result.count = n;
62
+ const n = parseUintValue(attrs[++i]?.value, MAX_UINT32_DIGITS);
63
+ if (n !== false) result.count = n;
54
64
  break;
55
65
  }
56
66
  case 'MIN': {
57
- const n = Number(attrs[++i]?.value);
58
- if (!isNaN(n)) result.min = n;
67
+ const n = parseUintValue(attrs[++i]?.value, MAX_UINT32_DIGITS);
68
+ if (n !== false) result.min = n;
59
69
  break;
60
70
  }
61
71
  case 'MAX': {
62
- const n = Number(attrs[++i]?.value);
63
- if (!isNaN(n)) result.max = n;
72
+ const n = parseUintValue(attrs[++i]?.value, MAX_UINT32_DIGITS);
73
+ if (n !== false) result.max = n;
64
74
  break;
65
75
  }
66
76
  case 'MODSEQ': {
67
77
  // RFC 7162 section 3.1.5: present when the SEARCH used a MODSEQ
68
78
  // criterion on a CONDSTORE-enabled session. BigInt because
69
79
  // mod-sequence values are unsigned 63-bit
70
- const value = attrs[++i]?.value;
71
- if (typeof value === 'string' && /^\d+$/.test(value)) result.modseq = BigInt(value);
80
+ const modseq = parseBigIntValue(attrs[++i]?.value);
81
+ if (modseq !== false) result.modseq = modseq;
72
82
  break;
73
83
  }
74
84
  case 'ALL': {
@@ -192,11 +202,37 @@ module.exports = async (connection, query, options) => {
192
202
  untagged: {
193
203
  SEARCH: async untagged => {
194
204
  if (untagged && untagged.attributes && untagged.attributes.length) {
195
- untagged.attributes.forEach(attribute => {
196
- if (attribute && attribute.value && typeof attribute.value === 'string' && !isNaN(attribute.value)) {
197
- results.add(Number(attribute.value));
205
+ let truncated = false;
206
+ let discarded = false;
207
+ for (let attribute of untagged.attributes) {
208
+ // The result set is server-controlled and accumulated across
209
+ // responses, so stop at the same absolute ceiling expandRange()
210
+ // uses - a server streaming SEARCH responses could otherwise
211
+ // grow the set until the process runs out of memory
212
+ /* c8 ignore next 4 */ // reaching the ceiling needs 2^24 accumulated results, which no unit test can produce in reasonable time
213
+ if (results.size >= EXPANDED_RANGE_LIMIT) {
214
+ truncated = true;
215
+ break;
198
216
  }
199
- });
217
+ // Same nz-number check the ESEARCH branch below applies. isNaN()
218
+ // is not enough: it passes '1e400' (Infinity), '-3' and '2.5', and
219
+ // a single one of those makes the sequence set compiled from this
220
+ // result set invalid, failing the caller's whole follow-up command
221
+ let value = attribute && typeof attribute.value === 'string' ? Number(attribute.value) : NaN;
222
+ if (!isValidSequenceValue(value)) {
223
+ discarded = true;
224
+ continue;
225
+ }
226
+ results.add(value);
227
+ }
228
+ if (truncated || discarded) {
229
+ connection.log.warn({
230
+ msg: 'Invalid entries in the SEARCH result',
231
+ truncated,
232
+ discarded,
233
+ cid: connection.id
234
+ });
235
+ }
200
236
  }
201
237
  },
202
238
 
@@ -222,7 +258,11 @@ module.exports = async (connection, query, options) => {
222
258
  // not contain '*' at all (RFC 9051 section 4.1.1), so UID
223
259
  // parts with '*' are dropped
224
260
  let existsCount = () => (connection.mailbox && connection.mailbox.exists) || 0;
225
- let overBudget = () => results.size >= existsCount();
261
+ // The mailbox EXISTS count is itself server-supplied and can be
262
+ // absurdly large, so the budget is additionally capped at the same
263
+ // absolute ceiling expandRange() uses - a hostile server cannot
264
+ // bypass it by inflating EXISTS first
265
+ let overBudget = () => results.size >= existsCount() || results.size >= EXPANDED_RANGE_LIMIT;
226
266
  let resolveId = part => (part === '*' ? (options.uid ? 0 : existsCount()) : Number(part));
227
267
  let truncated = false;
228
268
  let discarded = false;