imapflow 1.6.4 → 1.6.6

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 (46) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/lib/commands/append.js +26 -4
  4. package/lib/commands/compress.js +29 -18
  5. package/lib/commands/copyuid-parser.js +4 -2
  6. package/lib/commands/expunge.js +5 -2
  7. package/lib/commands/fetch.js +9 -3
  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-compiler.js +91 -60
  16. package/lib/handler/imap-parser.js +7 -0
  17. package/lib/handler/imap-stream.js +78 -12
  18. package/lib/handler/limits.js +16 -4
  19. package/lib/imap-flow.d.ts +22 -2
  20. package/lib/imap-flow.js +209 -94
  21. package/lib/jp-decoder.js +30 -5
  22. package/lib/limited-passthrough.js +19 -1
  23. package/lib/search-compiler.js +24 -16
  24. package/lib/tools.js +190 -39
  25. package/package.json +4 -4
  26. package/test/commands-branches-test.js +4 -0
  27. package/test/commands-integration-test.js +780 -5
  28. package/test/copyuid-parser-test.js +20 -0
  29. package/test/idle-polling-test.js +81 -0
  30. package/test/imap-compiler-test.js +74 -4
  31. package/test/imap-flow-coverage-test.js +4 -2
  32. package/test/imap-flow-fetch-download-test.js +26 -0
  33. package/test/imap-flow-internals-test.js +134 -0
  34. package/test/imap-flow-methods-test.js +92 -0
  35. package/test/imap-flow-secure-test.js +133 -116
  36. package/test/imap-flow-server-test.js +126 -0
  37. package/test/imap-parser-test.js +25 -0
  38. package/test/imap-stream-edge-cases-test.js +163 -3
  39. package/test/integration/rev2-live-test.js +30 -0
  40. package/test/jp-decoder-test.js +57 -0
  41. package/test/limited-passthrough-test.js +24 -0
  42. package/test/parser-limits-test.js +18 -0
  43. package/test/reliability-improvements-test.js +3 -3
  44. package/test/search-compiler-test.js +90 -3
  45. package/test/timer-policy-test.js +27 -1
  46. package/test/tools-test.js +151 -2
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.6.4"
2
+ ".": "1.6.6"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.6](https://github.com/postalsys/imapflow/compare/v1.6.5...v1.6.6) (2026-08-07)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * harden parsing of untrusted server response values ([1560424](https://github.com/postalsys/imapflow/commit/15604240b5fe5f7ec57514db17f41123140b53fe))
9
+
10
+ ## [1.6.5](https://github.com/postalsys/imapflow/compare/v1.6.4...v1.6.5) (2026-07-29)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * resolve regressions from the rev2 hardening commit and harden further ([0739df9](https://github.com/postalsys/imapflow/commit/0739df93709d2c7d572e977c2b37b6d219e69e30))
16
+
3
17
  ## [1.6.4](https://github.com/postalsys/imapflow/compare/v1.6.3...v1.6.4) (2026-07-29)
4
18
 
5
19
 
@@ -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
  }
@@ -19,26 +19,37 @@ module.exports = async connection => {
19
19
  let response;
20
20
  try {
21
21
  response = await connection.exec('COMPRESS', [{ type: 'ATOM', value: 'DEFLATE' }]);
22
- // Everything after the tagged OK is already deflate-framed (RFC 4978 section 4).
23
- // The socket stays piped into the plaintext parser until this call returns, so
24
- // bytes that arrived in the same chunk as the OK have been consumed as cleartext
25
- // and are missing from the head of the deflate stream - the inflater would then
26
- // fail and take the connection with it. Rare (it needs the server to write again
27
- // before we re-pipe), and staying uncompressed is a cheaper outcome than a dead
28
- // connection, so decline the upgrade instead.
29
- // This only covers the bytes the parser had already buffered when the OK was
30
- // handled. Closing the remaining window, and keeping compression rather than
31
- // dropping it, needs the stream to hand back its unconsumed tail on unpipe so
32
- // the transport can feed it into the inflater - not something a command module
33
- // can reach from here.
34
- response.next();
35
- if (response.hasTrailingData) {
36
- connection.log.warn({ msg: 'Server sent data immediately after the COMPRESS response, skipping compression', cid: connection.id });
37
- return false;
38
- }
39
- return true;
40
22
  } catch (err) {
23
+ // The server declined (NO/BAD): nothing switched, staying uncompressed is safe.
41
24
  connection.log.warn({ err, cid: connection.id });
42
25
  return false;
43
26
  }
27
+
28
+ // Everything after the tagged OK is already deflate-framed (RFC 4978 section 4) -
29
+ // the server switches at the OK, so declining the upgrade at this point is not a
30
+ // protocol option. The socket stays piped into the plaintext parser until the
31
+ // transport swaps in the inflater, so bytes that arrived in the same chunk as the
32
+ // OK have been consumed as cleartext and are missing from the head of the deflate
33
+ // stream: the session is unrecoverable in both directions. Fail it immediately
34
+ // (the same way STARTTLS treats post-OK trailing data) instead of letting it die
35
+ // slowly on garbage. Closing this window without failing needs the stream to hand
36
+ // back its unconsumed tail on unpipe so the transport can feed it into the
37
+ // inflater - not something a command module can reach from here.
38
+ if (response.hasTrailingData) {
39
+ let error = new Error('Server sent data between the COMPRESS response and the compression layer switch');
40
+ error.code = 'COMPRESS_TRAILING_DATA';
41
+ connection.log.error({ err: error, cid: connection.id });
42
+ // Schedule the close before releasing parser backpressure, so the buffered
43
+ // deflate-framed bytes cannot settle anything before teardown begins. This is
44
+ // why the decision lives here rather than at the connection layer the way the
45
+ // STARTTLS guard does (starttls.js records a flag, upgradeToSTARTTLS decides):
46
+ // only the command module holds the response before its backpressure release,
47
+ // so only it can order teardown ahead of that release.
48
+ connection.closeAfter();
49
+ response.next();
50
+ throw error;
51
+ }
52
+
53
+ response.next();
54
+ return true;
44
55
  };
@@ -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;
@@ -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;
@@ -1,6 +1,57 @@
1
1
  'use strict';
2
2
 
3
- const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
3
+ const { encodePath, normalizePath, enhanceCommandError, parseBigIntValue, parseUintValue, getStringList, MAX_UINT32_DIGITS } = require('../tools.js');
4
+
5
+ // Response codes carrying a value that SELECT/EXAMINE may write to the mailbox object, keyed by
6
+ // the lowercased code, mapped to the fixed public property name and the parser for the value.
7
+ // Every parser returns false for a value it cannot use, and the field is then left unset.
8
+ //
9
+ // This is an allowlist on purpose. The mailbox object is API surface: without one, an arbitrary
10
+ // server-sent [KEY value] code could overwrite `path` (defeating the DELETE/RENAME guards that
11
+ // compare paths) or `flags`, and a parenthesized value under "__proto__" would replace the
12
+ // object's prototype. The lookup itself is on a null-prototype object for the same reason - the
13
+ // key is lowercased, so "constructor" would otherwise resolve to an inherited member.
14
+ const VALUED_RESPONSE_CODES = Object.assign(Object.create(null), {
15
+ // CONDSTORE (RFC 7162): highest mod-sequence value for the mailbox, used for incremental
16
+ // sync. Stored as a BigInt since modseq values can exceed Number.MAX_SAFE_INTEGER.
17
+ //
18
+ // A value that is not a bounded digit run is dropped rather than stored raw: every consumer
19
+ // compares highestModseq relationally, and a relational compare against a non-numeric string
20
+ // is false in both directions, so the value could never advance and delta sync would stop.
21
+ highestmodseq: { key: 'highestModseq', parse: value => parseBigIntValue(value) },
22
+
23
+ // Unique identifier validity. If this changes between sessions, all previously cached UIDs
24
+ // are invalid and the client must re-sync from scratch. Nominally 32-bit, but stored as a
25
+ // BigInt precisely so a server that exceeds that still round-trips, hence the wider bound.
26
+ uidvalidity: { key: 'uidValidity', parse: value => parseBigIntValue(value) },
27
+
28
+ // The next UID to be assigned in this mailbox, useful for detecting new arrivals. A huge
29
+ // digit run would coerce to Infinity and corrupt every later UID range computation.
30
+ uidnext: { key: 'uidNext', parse: value => parseUintValue(value, MAX_UINT32_DIGITS) },
31
+
32
+ // Sequence number of the first unseen message (RFC 3501 section 7.1). Not a count of unseen
33
+ // messages - use mailboxStatus() with {unseen: true} for that.
34
+ unseen: { key: 'unseen', parse: value => parseUintValue(value, MAX_UINT32_DIGITS) },
35
+
36
+ // APPENDLIMIT (RFC 7889): largest message size in octets the server accepts for APPEND into
37
+ // this mailbox. Spelled all lowercase, unlike the camelCase fields around it, because that is
38
+ // the name this object has always exposed.
39
+ appendlimit: { key: 'appendlimit', parse: value => parseUintValue(value) },
40
+
41
+ // OBJECTID (RFC 8474): server-assigned mailbox identifier that survives renames. Sent as a
42
+ // parenthesized list, but servers in the wild send it bare too.
43
+ mailboxid: {
44
+ key: 'mailboxId',
45
+ parse: value => (Array.isArray(value) ? value.length > 0 && value[0] : typeof value === 'string' && value)
46
+ },
47
+
48
+ // Flags the client may change permanently on messages in this mailbox, including \* if the
49
+ // server allows custom flags. Only the parenthesized form carries flags, and a malformed
50
+ // value must leave permanentFlags unset rather than set an empty Set: canUseFlag() reads
51
+ // unset as permissive and empty as deny-all, so an empty Set would turn every later flag
52
+ // update into a silent no-op for the rest of the session.
53
+ permanentflags: { key: 'permanentFlags', parse: value => Array.isArray(value) && new Set(value) }
54
+ });
4
55
 
5
56
  /**
6
57
  * Selects or examines a mailbox, making it the current mailbox for subsequent operations.
@@ -92,78 +143,33 @@ module.exports = async (connection, path, options) => {
92
143
  }
93
144
  let section = !untagged.attributes[0].value && untagged.attributes[0].section;
94
145
  // Handle response codes with a key-value pair (section has 2+ elements)
95
- if (section && section.length > 1 && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
146
+ if (section && section.length > 1 && section[0] && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
96
147
  let key = section[0].value.toLowerCase();
97
148
  let value;
98
149
 
99
- // Value can be a single string or a list of strings (e.g., PERMANENTFLAGS)
100
- if (typeof section[1].value === 'string') {
150
+ // Value can be a single string or a list of strings (e.g., PERMANENTFLAGS).
151
+ // section[1] can be a parsed NIL (null), and so can any element inside a
152
+ // parenthesized list, so both levels need the guard
153
+ if (section[1] && typeof section[1].value === 'string') {
101
154
  value = section[1].value;
102
155
  } else if (Array.isArray(section[1])) {
103
- value = section[1].map(entry => (typeof entry.value === 'string' ? entry.value : false)).filter(entry => entry);
156
+ value = getStringList(section[1]);
104
157
  }
105
158
 
106
- switch (key) {
107
- // CONDSTORE (RFC 7162): highest mod-sequence value for the mailbox.
108
- // Used for incremental sync -- clients compare against their cached
109
- // value to detect changes. Stored as BigInt since modseq values
110
- // can exceed Number.MAX_SAFE_INTEGER.
111
- case 'highestmodseq':
112
- key = 'highestModseq';
113
- if (/^[0-9]+$/.test(value)) {
114
- value = BigInt(value);
115
- }
116
- break;
117
-
118
- // OBJECTID (RFC 8474): server-assigned unique mailbox identifier.
119
- // Unlike path, this ID survives renames. Value comes as a
120
- // parenthesized list, so extract the first (only) element.
121
- case 'mailboxid':
122
- key = 'mailboxId';
123
- if (Array.isArray(value) && value.length) {
124
- value = value[0];
125
- }
126
- break;
127
-
128
- // Flags that the client can change permanently on messages in
129
- // this mailbox. Includes \* if the server allows custom flags.
130
- case 'permanentflags':
131
- key = 'permanentFlags';
132
- value = new Set(value);
133
- break;
134
-
135
- // The next UID that will be assigned to a new message in this
136
- // mailbox. Useful for detecting new arrivals.
137
- case 'uidnext':
138
- key = 'uidNext';
139
- value = Number(value);
140
- break;
141
-
142
- // Unique identifier validity value. If this changes between
143
- // sessions, all previously cached UIDs are invalid and the
144
- // client must re-sync from scratch.
145
- case 'uidvalidity':
146
- key = 'uidValidity';
147
- if (/^[0-9]+$/.test(value)) {
148
- value = BigInt(value);
149
- }
150
- break;
159
+ let field = VALUED_RESPONSE_CODES[key];
160
+ if (field) {
161
+ let parsed = field.parse(value);
162
+ if (parsed !== false) {
163
+ map[field.key] = parsed;
164
+ }
151
165
  }
152
-
153
- map[key] = value;
154
166
  }
155
167
 
156
- // Handle response codes with only a keyword (no value), e.g., [NOMODSEQ]
157
- if (section && section.length === 1 && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
158
- let key = section[0].value.toLowerCase();
159
- switch (key) {
160
- // NOMODSEQ means the mailbox does not support mod-sequences.
161
- // CONDSTORE/QRESYNC features are unavailable for this mailbox.
162
- case 'nomodseq':
163
- key = 'noModseq';
164
- map[key] = true;
165
- break;
166
- }
168
+ // Handle response codes with only a keyword (no value), e.g., [NOMODSEQ].
169
+ // NOMODSEQ means the mailbox does not support mod-sequences, so the
170
+ // CONDSTORE/QRESYNC features are unavailable for it.
171
+ if (section && section.length === 1 && section[0] && section[0].type === 'ATOM' && section[0].value?.toUpperCase() === 'NOMODSEQ') {
172
+ map.noModseq = true;
167
173
  }
168
174
  },
169
175
 
@@ -173,15 +179,16 @@ module.exports = async (connection, path, options) => {
173
179
  if (!untagged.attributes || !untagged.attributes.length || !Array.isArray(untagged.attributes[0])) {
174
180
  return;
175
181
  }
176
- let flags = untagged.attributes[0].map(flag => (typeof flag.value === 'string' ? flag.value : false)).filter(flag => flag);
177
- map.flags = new Set(flags);
182
+ map.flags = new Set(getStringList(untagged.attributes[0]));
178
183
  },
179
184
 
180
185
  // Untagged EXISTS response: "* <count> EXISTS" tells us the total number
181
186
  // of messages in the mailbox. The count is in the command field (numeric prefix).
182
187
  EXISTS: async untagged => {
183
- let num = Number(untagged.command);
184
- if (isNaN(num)) {
188
+ // Not a usable count: anything but a bounded digit run. A long digit run
189
+ // coerces to Infinity, which would corrupt every later range computation
190
+ let num = parseUintValue(untagged.command, MAX_UINT32_DIGITS);
191
+ if (num === false) {
185
192
  return false;
186
193
  }
187
194
 
@@ -204,9 +211,13 @@ module.exports = async (connection, path, options) => {
204
211
  });
205
212
 
206
213
  // The tagged OK response to SELECT/EXAMINE includes [READ-ONLY] or [READ-WRITE]
207
- // in its response code, indicating the access mode the server granted.
208
- let section = !response.response.attributes[0].value && response.response.attributes[0].section;
209
- if (section && section.length && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
214
+ // in its response code, indicating the access mode the server granted. A tagged OK
215
+ // with no resp-text has no `attributes` property at all, and unlike the untagged
216
+ // handlers above this runs in the command body, where a throw would tear down the
217
+ // mailbox state the server has actually selected.
218
+ let okAttributes = (response.response && response.response.attributes) || [];
219
+ let section = okAttributes[0] && !okAttributes[0].value && okAttributes[0].section;
220
+ if (section && section.length && section[0] && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
210
221
  map.readOnly = section[0].value.toUpperCase() === 'READ-ONLY';
211
222
  }
212
223