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,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
 
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ const { parseBigIntValue, parseUintValue, MAX_UINT32_DIGITS } = require('../tools.js');
4
+
5
+ // STATUS data items (RFC 3501 section 6.3.10, RFC 7162 for HIGHESTMODSEQ, RFC 9051 for SIZE
6
+ // and DELETED) mapped to the property name each one is exposed under, together with the
7
+ // parser that turns the raw response token into a usable value. Shared by the STATUS command
8
+ // and by the inline STATUS responses of LIST-STATUS (RFC 5819) so the two cannot drift apart.
9
+ //
10
+ // Every parser rejects anything that is not a bounded decimal digit run, returning false.
11
+ // These values are server-controlled and several of them are written straight into the live
12
+ // mailbox state, where a NaN or a value coerced to Infinity corrupts every later range
13
+ // computation. A plain isNaN() test is not enough: it passes '1e5', ' 12 ' and 'Infinity',
14
+ // and BigInt() throws on all three, aborting the walk over the remaining fields.
15
+ const uint32 = value => parseUintValue(value, MAX_UINT32_DIGITS);
16
+
17
+ const STATUS_FIELDS = {
18
+ MESSAGES: { key: 'messages', parser: uint32 },
19
+ RECENT: { key: 'recent', parser: uint32 },
20
+ UIDNEXT: { key: 'uidNext', parser: uint32 },
21
+ // Nominally 32-bit, but stored as a BigInt precisely so a server that exceeds that still
22
+ // round-trips, so the wider bound applies
23
+ UIDVALIDITY: { key: 'uidValidity', parser: value => parseBigIntValue(value) },
24
+ UNSEEN: { key: 'unseen', parser: uint32 },
25
+ HIGHESTMODSEQ: { key: 'highestModseq', parser: value => parseBigIntValue(value) },
26
+ // IMAP4rev2 additions (RFC 9051): total mailbox size in octets (number64, exact as a JS
27
+ // number up to 2^53-1) and count of messages carrying the \Deleted flag
28
+ SIZE: { key: 'size', parser: value => parseUintValue(value) },
29
+ DELETED: { key: 'deleted', parser: uint32 }
30
+ };
31
+
32
+ /**
33
+ * Walks a STATUS data-item list - alternating item-name and item-value tokens - and reports
34
+ * every recognized field that parsed successfully. Unknown item names and unusable values are
35
+ * skipped, so one bad field never costs the rest of the response.
36
+ *
37
+ * @param {Array} list - Parsed attribute list from the untagged STATUS response.
38
+ * @param {Function} onField - Called as (key, value) for each usable field.
39
+ */
40
+ const parseStatusList = (list, onField) => {
41
+ let name;
42
+ list.forEach((entry, i) => {
43
+ if (i % 2 === 0) {
44
+ name = entry && typeof entry.value === 'string' ? entry.value : false;
45
+ return;
46
+ }
47
+
48
+ if (!name || !entry) {
49
+ return;
50
+ }
51
+
52
+ // The item name is server-controlled, but uppercasing it before the lookup means no
53
+ // Object.prototype member can be reached: every builtin name has a lowercase letter.
54
+ const field = STATUS_FIELDS[name.toUpperCase()];
55
+ if (!field) {
56
+ return;
57
+ }
58
+
59
+ const value = field.parser(entry.value);
60
+ if (value === false) {
61
+ return;
62
+ }
63
+
64
+ onField(field.key, value);
65
+ });
66
+ };
67
+
68
+ module.exports = { parseStatusList };
@@ -1,6 +1,25 @@
1
1
  'use strict';
2
2
 
3
3
  const { encodePath, normalizePath, buildStatusQueryAttributes, isRev2Active } = require('../tools.js');
4
+ const { parseStatusList } = require('./status-fields.js');
5
+
6
+ // STATUS fields that also refresh the live mailbox state when the queried mailbox is the
7
+ // currently selected one. Keyed by the output property name parseStatusList() reports.
8
+ const MAILBOX_UPDATERS = {
9
+ messages: (value, connection, path) => {
10
+ let prevCount = connection.mailbox.exists;
11
+ if (prevCount !== value) {
12
+ connection.mailbox.exists = value;
13
+ connection.emit('exists', { path, count: value, prevCount });
14
+ }
15
+ },
16
+ uidNext: (value, connection) => {
17
+ connection.mailbox.uidNext = value;
18
+ },
19
+ highestModseq: (value, connection) => {
20
+ connection.mailbox.highestModseq = value;
21
+ }
22
+ };
4
23
 
5
24
  /**
6
25
  * Requests status information about a mailbox.
@@ -56,68 +75,11 @@ module.exports = async (connection, path, query) => {
56
75
  if (!list) {
57
76
  return;
58
77
  }
59
- // Maps IMAP STATUS field names to their output key names, type parsers,
60
- // and optional callbacks to update the live mailbox state.
61
- const STATUS_FIELD_MAP = {
62
- MESSAGES: {
63
- key: 'messages',
64
- parser: Number,
65
- updateMailbox: (val, conn) => {
66
- let prevCount = conn.mailbox.exists;
67
- if (prevCount !== val) {
68
- conn.mailbox.exists = val;
69
- conn.emit('exists', { path, count: val, prevCount });
70
- }
71
- }
72
- },
73
- RECENT: { key: 'recent', parser: Number },
74
- UIDNEXT: {
75
- key: 'uidNext',
76
- parser: Number,
77
- updateMailbox: (val, conn) => {
78
- conn.mailbox.uidNext = val;
79
- }
80
- },
81
- UIDVALIDITY: { key: 'uidValidity', parser: BigInt },
82
- UNSEEN: { key: 'unseen', parser: Number },
83
- HIGHESTMODSEQ: {
84
- key: 'highestModseq',
85
- parser: BigInt,
86
- updateMailbox: (val, conn) => {
87
- conn.mailbox.highestModseq = val;
88
- }
89
- },
90
- // IMAP4rev2 additions (RFC 9051): total mailbox size in octets
91
- // (number64, exact as a JS number up to 2^53-1) and count of
92
- // messages with the \Deleted flag
93
- SIZE: { key: 'size', parser: Number },
94
- DELETED: { key: 'deleted', parser: Number }
95
- };
96
-
97
- let key;
98
- list.forEach((entry, i) => {
99
- if (i % 2 === 0) {
100
- key = entry && typeof entry.value === 'string' ? entry.value : false;
101
- return;
102
- }
103
- if (!key || !entry || typeof entry.value !== 'string') {
104
- return;
105
- }
106
-
107
- const fieldConfig = STATUS_FIELD_MAP[key.toUpperCase()];
108
- if (!fieldConfig) {
109
- return;
110
- }
111
-
112
- const value = !isNaN(entry.value) ? fieldConfig.parser(entry.value) : false;
113
- if (value === false) {
114
- return;
115
- }
116
-
117
- map[fieldConfig.key] = value;
78
+ parseStatusList(list, (key, value) => {
79
+ map[key] = value;
118
80
 
119
- if (updateCurrent && fieldConfig.updateMailbox) {
120
- fieldConfig.updateMailbox(value, connection);
81
+ if (updateCurrent && MAILBOX_UPDATERS[key]) {
82
+ MAILBOX_UPDATERS[key](value, connection, path);
121
83
  }
122
84
  });
123
85
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  const Transform = require('stream').Transform;
4
4
  const logger = require('../logger');
5
- const { MAX_LITERAL_SIZE, MAX_LINE_SIZE, normalizeLimit, createLiteralTooLargeError } = require('./limits');
5
+ const { MAX_LITERAL_SIZE, MAX_LINE_SIZE, MAX_RESPONSE_SIZE, normalizeLimit, createLiteralTooLargeError } = require('./limits');
6
6
 
7
7
  const LINE = 0x01;
8
8
  const LITERAL = 0x02;
@@ -44,6 +44,16 @@ class ImapStream extends Transform {
44
44
  * exactly at the limit is accepted. Exceeding it is terminal: the stream is destroyed with a
45
45
  * `LiteralTooLarge` error, the marker line is not emitted, and no byte of the rejected
46
46
  * literal body is parsed as protocol.
47
+ * @param {number} [options.maxResponseSize] - Maximum allowed total size (in bytes) of a
48
+ * single assembled response: every line segment and literal of one response combined.
49
+ * Defaults to MAX_RESPONSE_SIZE (2GB), which leaves room above the literal cap for a
50
+ * maximum-size literal plus its marker line. The per-line and per-literal caps alone
51
+ * cannot stop a server that spreads attacker-controlled bytes across an unbounded
52
+ * number of tokens of a single response. Declared literal sizes count when their
53
+ * marker is parsed, so an oversized total is rejected before the literal bytes arrive,
54
+ * and a line still being assembled counts against whatever budget is left.
55
+ * Exceeding the limit is terminal: the stream is destroyed with a `ResponseTooLarge`
56
+ * error and no further input is parsed.
47
57
  */
48
58
  constructor(options) {
49
59
  super({
@@ -73,6 +83,8 @@ class ImapStream extends Transform {
73
83
  // announcing an oversized literal cannot exhaust memory.
74
84
  this.maxLiteralSize = normalizeLimit(this.options.maxLiteralSize, MAX_LITERAL_SIZE);
75
85
 
86
+ this.maxResponseSize = normalizeLimit(this.options.maxResponseSize, MAX_RESPONSE_SIZE);
87
+
76
88
  this.state = LINE;
77
89
  this.literalWaiting = 0;
78
90
  this.inputBuffer = []; // lines
@@ -80,6 +92,7 @@ class ImapStream extends Transform {
80
92
  this.lineBytes = 0; // bytes currently buffered for the in-progress line
81
93
  this.literalBuffer = [];
82
94
  this.literals = [];
95
+ this.responseBytes = 0; // bytes accumulated for the in-progress response (lines + declared literals)
83
96
 
84
97
  this.compress = false;
85
98
  this.secureConnection = this.options.secureConnection;
@@ -228,6 +241,33 @@ class ImapStream extends Transform {
228
241
  return this.failStream(err);
229
242
  }
230
243
 
244
+ /**
245
+ * Enforces the configured per-response size cap: the cumulative bytes of every line
246
+ * segment and declared literal of the response currently being assembled. Counting
247
+ * declared literal sizes at marker time means an oversized total is rejected before
248
+ * the literal bytes even arrive. The counter is reset when a response is emitted.
249
+ *
250
+ * @param {number} additionalBytes - Bytes the next token would add to the response.
251
+ * @param {boolean} [peek] - Measure only, without committing the bytes to the counter.
252
+ * Used for a line that is still being assembled: its bytes are committed once, when the
253
+ * line completes.
254
+ * @returns {boolean} True if within the limit, false if the stream was failed.
255
+ */
256
+ checkResponseSize(additionalBytes, peek) {
257
+ let total = this.responseBytes + additionalBytes;
258
+ if (total <= this.maxResponseSize) {
259
+ if (!peek) {
260
+ this.responseBytes = total;
261
+ }
262
+ return true;
263
+ }
264
+ const err = new Error(`Response size ${total} exceeds maximum allowed size of ${this.maxResponseSize} bytes`);
265
+ err.code = 'ResponseTooLarge';
266
+ err.responseSize = total;
267
+ err.maxSize = this.maxResponseSize;
268
+ return this.failStream(err);
269
+ }
270
+
231
271
  /**
232
272
  * Processes a single input chunk of raw data. In LINE state, scans for LF-terminated
233
273
  * lines and checks for literal markers. In LITERAL state, collects the expected number
@@ -273,6 +313,13 @@ class ImapStream extends Transform {
273
313
  return;
274
314
  }
275
315
 
316
+ // Count the line itself and, for a literal marker, the declared
317
+ // literal bytes against the cumulative per-response budget, so a
318
+ // response assembled from many tokens stays bounded as a whole
319
+ if (!this.checkResponseSize(line.length + (isLiteralMarker ? this.literalWaiting : 0))) {
320
+ return;
321
+ }
322
+
276
323
  this.inputBuffer.push(line);
277
324
 
278
325
  if (isLiteralMarker) {
@@ -285,6 +332,7 @@ class ImapStream extends Transform {
285
332
  let literals = this.literals;
286
333
  this.inputBuffer = [];
287
334
  this.literals = [];
335
+ this.responseBytes = 0;
288
336
 
289
337
  if (payload.length) {
290
338
  // remove final line terminator (\n or \r\n)
@@ -323,7 +371,12 @@ class ImapStream extends Transform {
323
371
  // No line terminator was found in the remaining bytes; carry the tail over to
324
372
  // the next chunk after measuring the line it belongs to.
325
373
  let tail = chunk.slice(lineStart);
326
- if (!this.checkLineLength(this.lineBytes + tail.length)) {
374
+ // The response counter is only committed when a line completes, so an
375
+ // in-progress line is measured against the remaining budget separately.
376
+ // Without this a response cap lowered to bound parser memory buys nothing
377
+ // while a server streams a line that never terminates - only the much
378
+ // larger line cap would hold it back.
379
+ if (!this.checkLineLength(this.lineBytes + tail.length) || !this.checkResponseSize(this.lineBytes + tail.length, true)) {
327
380
  return;
328
381
  }
329
382
  this.lineBytes += tail.length;
@@ -453,6 +506,7 @@ class ImapStream extends Transform {
453
506
  this.lineBytes = 0;
454
507
  this.literalBuffer = [];
455
508
  this.literals = [];
509
+ this.responseBytes = 0;
456
510
 
457
511
  // Settle an in-flight push() wait so processInput() can unwind
458
512
  if (typeof this.pendingPush === 'function') {
@@ -12,16 +12,28 @@ const MAX_LITERAL_SIZE = 1024 * 1024 * 1024;
12
12
  // only to stop a server that never sends a line terminator, not to constrain normal traffic.
13
13
  const MAX_LINE_SIZE = MAX_LITERAL_SIZE;
14
14
 
15
+ // Default maximum total size of a single assembled response: every line segment and literal of
16
+ // one response combined. The per-line and per-literal caps alone cannot stop a server that
17
+ // spreads attacker-controlled bytes across an unbounded number of tokens of a single response
18
+ // (e.g. one FETCH answer carrying many maximum-size literals).
19
+ //
20
+ // Deliberately above the literal cap: the response total also carries the literal's marker line
21
+ // and the rest of the response framing, so a cap equal to MAX_LITERAL_SIZE would make a literal
22
+ // of exactly the maximum permitted size impossible to receive. Configuring both limits calls for
23
+ // the same headroom - set maxResponseSize above maxLiteralSize, not equal to it.
24
+ const MAX_RESPONSE_SIZE = 2 * MAX_LITERAL_SIZE;
25
+
15
26
  /**
16
27
  * Normalizes a configured size limit. A non-negative integer is honored as-is (including 0, which
17
- * means "reject anything non-empty"); anything else falls back to the default, so an explicit 0 is
18
- * not silently swallowed the way `value || DEFAULT` would swallow it.
28
+ * means "reject anything non-empty"), and `Infinity` disables the limit; anything else falls back
29
+ * to the default, so an explicit 0 is not silently swallowed the way `value || DEFAULT` would
30
+ * swallow it.
19
31
  *
20
32
  * @param {*} value - The configured value.
21
33
  * @param {number} defaultValue - Fallback when the value is not a usable limit.
22
34
  * @returns {number} The normalized limit.
23
35
  */
24
- const normalizeLimit = (value, defaultValue) => (Number.isInteger(value) && value >= 0 ? value : defaultValue);
36
+ const normalizeLimit = (value, defaultValue) => ((Number.isInteger(value) || value === Infinity) && value >= 0 ? value : defaultValue);
25
37
 
26
38
  /**
27
39
  * Builds the `LiteralTooLarge` error. One shape for every place a literal is refused, so callers
@@ -40,4 +52,4 @@ const createLiteralTooLargeError = (literalSize, maxSize, reason) => {
40
52
  return err;
41
53
  };
42
54
 
43
- module.exports = { MAX_LITERAL_SIZE, MAX_LINE_SIZE, normalizeLimit, createLiteralTooLargeError };
55
+ module.exports = { MAX_LITERAL_SIZE, MAX_LINE_SIZE, MAX_RESPONSE_SIZE, normalizeLimit, createLiteralTooLargeError };
@@ -30,6 +30,17 @@ export interface ImapFlowOptions {
30
30
  clientInfo?: IdInfoObject;
31
31
  /** If true, then do not start IDLE when connection is established */
32
32
  disableAutoIdle?: boolean;
33
+ /**
34
+ * How long (in ms) the connection has to be inactive before IDLE is started automatically.
35
+ * Keep it above the pause your own code usually leaves between two commands, otherwise every
36
+ * command is followed by an IDLE that the next command has to break, costing two extra
37
+ * round-trips per command. To turn auto-IDLE off use `disableAutoIdle` rather than a very
38
+ * large delay: the value is capped below `socketTimeout`, because auto-IDLE has to start
39
+ * before the inactivity watchdog fires. On servers without IDLE support this controls when
40
+ * the polling fallback starts, not how often it polls - the poll interval is `maxIdleTime`,
41
+ * capped at 2 minutes. Default: 15000 ms.
42
+ */
43
+ autoIdleDelay?: number;
33
44
  /** Additional TLS options (see Node.js TLS documentation) */
34
45
  tls?: ConnectionOptions;
35
46
  /** Custom logger instance. Set to false to disable logging */
@@ -85,18 +96,34 @@ export interface ImapFlowOptions {
85
96
  * Maximum allowed length in bytes of a single response line (a response without a literal).
86
97
  * Guards against a malicious or broken server that never sends a line terminator. Defaults to
87
98
  * 1GB. The line terminator counts towards the limit and a line exactly at the limit is
88
- * accepted. Exceeding it is terminal: the connection fails with error code `LineTooLarge` and
99
+ * accepted. `Infinity` disables the limit. An in-progress line is additionally bounded by
100
+ * whatever is left of `maxResponseSize`, so lowering that also bounds line buffering.
101
+ * Exceeding it is terminal: the connection fails with error code `LineTooLarge` and
89
102
  * no further input is parsed.
90
103
  */
91
104
  maxLineLength?: number;
92
105
  /**
93
106
  * Maximum allowed size in bytes of a single IMAP literal block. Bounds peak memory allocation
94
107
  * against a malicious or broken server announcing an oversized literal. Defaults to 1GB. A
95
- * literal exactly at the limit is accepted. Exceeding it is terminal: the connection fails
108
+ * literal exactly at the limit is accepted, provided `maxResponseSize` leaves room for the
109
+ * marker line as the defaults do. `Infinity` disables the limit. Exceeding it is terminal: the connection fails
96
110
  * with error code `LiteralTooLarge`, and neither the marker line nor any byte of the rejected
97
111
  * literal is interpreted as protocol.
98
112
  */
99
113
  maxLiteralSize?: number;
114
+ /**
115
+ * Maximum allowed total size in bytes of a single assembled IMAP response (every line
116
+ * segment and literal of one response combined). Bounds peak memory allocation against
117
+ * a malicious or broken server that spreads response data across an unbounded number of
118
+ * tokens, which the per-line and per-literal caps alone cannot stop. Defaults to 2GB,
119
+ * which is above the default literal cap on purpose: the total also carries the literal
120
+ * marker line and the rest of the response framing, so a value equal to `maxLiteralSize`
121
+ * would make a literal of exactly the maximum permitted size impossible to receive. Set
122
+ * this above `maxLiteralSize` when configuring both. `Infinity` disables the limit.
123
+ * Exceeding it is terminal: the connection fails with error code `ResponseTooLarge` and
124
+ * no further input is parsed.
125
+ */
126
+ maxResponseSize?: number;
100
127
  /**
101
128
  * Threshold in milliseconds for warning that a mailbox lock has been held
102
129
  * for a long time (diagnostic for forgotten release() calls). Defaults to
@@ -145,6 +172,10 @@ export interface MailboxObject {
145
172
  uidNext: number;
146
173
  /** Messages in this folder */
147
174
  exists: number;
175
+ /** Sequence number of the first unseen message, if the server reported [UNSEEN] on SELECT. Not a count of unseen messages - use mailboxStatus() with {unseen: true} for that */
176
+ unseen?: number;
177
+ /** Largest message size in octets the server accepts for APPEND into this mailbox, if it reported [APPENDLIMIT] (RFC 7889) */
178
+ appendlimit?: number;
148
179
  /** Read-only state */
149
180
  readOnly?: boolean;
150
181
  }