imapflow 2.5.0 → 2.5.2

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 (38) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cjs/commands/authenticate.js +32 -1
  3. package/dist/cjs/commands/fetch.js +10 -3
  4. package/dist/cjs/commands/idle.js +31 -1
  5. package/dist/cjs/commands/list.js +14 -0
  6. package/dist/cjs/errors.d.ts +1 -0
  7. package/dist/cjs/errors.js +2 -0
  8. package/dist/cjs/handler/imap-parser.js +3 -0
  9. package/dist/cjs/handler/parser-instance.d.ts +2 -0
  10. package/dist/cjs/handler/parser-instance.js +3 -1
  11. package/dist/cjs/handler/token-parser.d.ts +21 -2
  12. package/dist/cjs/handler/token-parser.js +76 -6
  13. package/dist/cjs/handler/types.d.ts +5 -0
  14. package/dist/cjs/imap-flow.js +37 -5
  15. package/dist/cjs/package-info.d.ts +1 -1
  16. package/dist/cjs/package-info.js +1 -1
  17. package/dist/cjs/tools.d.ts +21 -0
  18. package/dist/cjs/tools.js +118 -52
  19. package/dist/cjs/types.d.ts +2 -1
  20. package/dist/esm/commands/authenticate.js +32 -1
  21. package/dist/esm/commands/fetch.js +11 -4
  22. package/dist/esm/commands/idle.js +31 -1
  23. package/dist/esm/commands/list.js +15 -1
  24. package/dist/esm/errors.d.ts +1 -0
  25. package/dist/esm/errors.js +2 -0
  26. package/dist/esm/handler/imap-parser.js +3 -0
  27. package/dist/esm/handler/parser-instance.d.ts +2 -0
  28. package/dist/esm/handler/parser-instance.js +3 -1
  29. package/dist/esm/handler/token-parser.d.ts +21 -2
  30. package/dist/esm/handler/token-parser.js +76 -6
  31. package/dist/esm/handler/types.d.ts +5 -0
  32. package/dist/esm/imap-flow.js +38 -6
  33. package/dist/esm/package-info.d.ts +1 -1
  34. package/dist/esm/package-info.js +1 -1
  35. package/dist/esm/tools.d.ts +21 -0
  36. package/dist/esm/tools.js +115 -51
  37. package/dist/esm/types.d.ts +2 -1
  38. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.5.2](https://github.com/postalsys/imapflow/compare/v2.5.1...v2.5.2) (2026-10-16)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * bump @zone-eu/mailsplit to 5.4.23 ([e758c81](https://github.com/postalsys/imapflow/commit/e758c816a608a75a9553f34d8790abaf7004a328))
9
+ * bump libmime to 5.4.9 and libqp to 2.1.3 ([0ea7730](https://github.com/postalsys/imapflow/commit/0ea77302e898bc5b1b62651bf32c97383df7de62))
10
+
11
+ ## [2.5.1](https://github.com/postalsys/imapflow/compare/v2.5.0...v2.5.1) (2026-10-16)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * bound what a hostile server can make the client buffer or wait for, and keep a tagged BYE from passing as success ([9b969a7](https://github.com/postalsys/imapflow/commit/9b969a75090b84e3cbdea408ac4f36035eb2e71b))
17
+
3
18
  ## [2.5.0](https://github.com/postalsys/imapflow/compare/v2.4.3...v2.5.0) (2026-10-16)
4
19
 
5
20
 
@@ -2,6 +2,35 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.default = authenticate;
4
4
  const tools_js_1 = require("../tools.js");
5
+ const errors_js_1 = require("../errors.js");
6
+ // The OAuth SASL payloads are framed with \x01 (RFC 7628 section 3.1, and the XOAUTH2 format
7
+ // copies it), so a control character in a value ends the field early and the rest of the value
8
+ // becomes fields of its own, auth= included. RFC 7628 allows only VCHAR, SP, HTAB, CR and LF in
9
+ // a value, and none of the controls has a place in a username or a bearer token.
10
+ const RE_CONTROL_CHARS = /[\x00-\x1f\x7f]/;
11
+ /**
12
+ * Refuses a username or token that could not be framed as one SASL field.
13
+ *
14
+ * @param value - The value about to be placed in the payload
15
+ * @param what - What the value is, for the error message
16
+ * @throws If the value contains a control character
17
+ */
18
+ function assertSaslValue(value, what) {
19
+ if (RE_CONTROL_CHARS.test(value)) {
20
+ throw (0, errors_js_1.createImapError)(`The ${what} contains control characters`, 'InvalidStringValue');
21
+ }
22
+ }
23
+ /**
24
+ * Escapes a value for the authzid of a GS2 header (RFC 5801 section 4): "," and "=" delimit
25
+ * the header, so they are written as =2C and =3D. "=" goes first, so the "=" the escaping
26
+ * itself introduces is not escaped again.
27
+ *
28
+ * @param value - The saslname
29
+ * @returns The escaped saslname
30
+ */
31
+ function escapeSaslName(value) {
32
+ return value.replace(/[=]/g, '=3D').replace(/,/g, '=2C');
33
+ }
5
34
  /**
6
35
  * Handles authentication errors by enriching the error object with server response details.
7
36
  *
@@ -37,6 +66,8 @@ async function authOauth(connection, username, accessToken) {
37
66
  let oauthbearer;
38
67
  let command;
39
68
  let breaker;
69
+ assertSaslValue(username, 'username');
70
+ assertSaslValue(accessToken, 'access token');
40
71
  if (connection.capabilities.has('AUTH=OAUTHBEARER')) {
41
72
  // OAUTHBEARER payload per RFC 7628: fields separated by \x01 (SASL GS2 framing).
42
73
  // Format: "n,a=<user>," \x01 "host=..." \x01 "port=..." \x01 "auth=Bearer <token>" \x01 \x01
@@ -47,7 +78,7 @@ async function authOauth(connection, username, accessToken) {
47
78
  // A server validating either field rejects with status `invalid_request` - a permanent
48
79
  // failure that refreshing the access token can never clear.
49
80
  oauthbearer = [
50
- `n,a=${username},`,
81
+ `n,a=${escapeSaslName(username)},`,
51
82
  `host=${connection.servername || connection.host}`,
52
83
  `port=${connection.port}`,
53
84
  `auth=Bearer ${accessToken}`,
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.default = fetch;
4
4
  const tools_js_1 = require("../tools.js");
5
+ const errors_js_1 = require("../errors.js");
5
6
  /**
6
7
  * Fetches emails from the server.
7
8
  *
@@ -38,9 +39,10 @@ async function fetch(connection, range, query, options) {
38
39
  count: 0,
39
40
  list: []
40
41
  };
41
- // The first error the onUntaggedFetch consumer reported through next(err). Errors thrown
42
- // by untagged handlers are only logged by the connection, so it is kept here and fails
43
- // the command once the FETCH completes; later messages are no longer handed to the consumer.
42
+ // The first error the onUntaggedFetch consumer reported through next(err), or the row
43
+ // limit being reached. Errors thrown by untagged handlers are only logged by the
44
+ // connection, so it is kept here and fails the command once the FETCH completes; later
45
+ // messages are no longer handed to the consumer or collected.
44
46
  let consumerError = null;
45
47
  let response;
46
48
  try {
@@ -235,6 +237,11 @@ async function fetch(connection, range, query, options) {
235
237
  consumerError = err;
236
238
  }
237
239
  }
240
+ else if (messages.list.length >= tools_js_1.MAX_COLLECTED_RESPONSES) {
241
+ // The rows are the server's to send and the memory holding them is
242
+ // this process's, see MAX_COLLECTED_RESPONSES
243
+ consumerError = (0, errors_js_1.createImapError)(`FETCH returned more than ${tools_js_1.MAX_COLLECTED_RESPONSES} messages`, 'TooManyResponses');
244
+ }
238
245
  else {
239
246
  messages.list.push(formatted);
240
247
  }
@@ -40,6 +40,12 @@ async function runIdle(connection) {
40
40
  // connection, so a newer IDLE session that already installed its own is left alone.
41
41
  let ownPreCheck = null;
42
42
  let releaseIdling = claimIdling(connection);
43
+ // The deadline for the server's "+" continuation, see the onSend handler below
44
+ let continuationTimer = null;
45
+ let clearContinuationTimer = () => {
46
+ (0, tools_js_1.clearTimer)(continuationTimer);
47
+ continuationTimer = null;
48
+ };
43
49
  try {
44
50
  // State flags for the IDLE lifecycle:
45
51
  // - doneRequested: someone wants to break IDLE (e.g., to run another command)
@@ -105,6 +111,7 @@ async function runIdle(connection) {
105
111
  // After this, the server will push untagged responses for mailbox changes.
106
112
  // We can now safely send DONE if a break was already requested.
107
113
  onPlusTag: async () => {
114
+ clearContinuationTimer();
108
115
  connection.log.debug({
109
116
  msg: `Initiated IDLE, waiting for server input`,
110
117
  lockId: connection.currentLock ? connection.currentLock.lockId : undefined,
@@ -121,7 +128,29 @@ async function runIdle(connection) {
121
128
  }
122
129
  }
123
130
  },
124
- onSend: () => { }
131
+ // The server has socketTimeout from the moment IDLE is on the wire to acknowledge it
132
+ // with the "+". The socket watchdog only covers a server that goes quiet: one that
133
+ // keeps sending untagged lines without ever answering "+" kept the IDLE in flight for
134
+ // good, and every later command queued behind it. DONE can not be sent before the
135
+ // "+" (RFC 2177 section 3), so there is no way to break such an IDLE and fall back
136
+ // to polling; the connection is failed the way the watchdog fails a dead peer.
137
+ onSend: () => {
138
+ continuationTimer = (0, tools_js_1.unrefTimer)(setTimeout(() => {
139
+ continuationTimer = null;
140
+ if (canEnd || connection.isClosed) {
141
+ return;
142
+ }
143
+ connection.log.error({
144
+ msg: 'Server did not acknowledge IDLE, closing the connection',
145
+ timeout: connection.socketTimeout,
146
+ cid: connection.id
147
+ });
148
+ connection.emitError((0, errors_js_1.createImapError)('IDLE was not acknowledged within the socket timeout', 'ETIMEOUT', {
149
+ command: 'IDLE',
150
+ details: { socketTimeout: connection.socketTimeout }
151
+ }));
152
+ }, connection.socketTimeout));
153
+ }
125
154
  });
126
155
  response.next();
127
156
  return;
@@ -153,6 +182,7 @@ async function runIdle(connection) {
153
182
  // (including a server-terminated IDLE, where preCheck never ran), rejected command,
154
183
  // parser failure and connection close. `idling` drives socket-timeout handling, so it
155
184
  // must always describe reality rather than being left over from a previous state.
185
+ clearContinuationTimer();
156
186
  releaseIdling();
157
187
  if (connection.preCheck === ownPreCheck) {
158
188
  connection.preCheck = false;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.default = list;
4
4
  const tools_js_1 = require("../tools.js");
5
+ const errors_js_1 = require("../errors.js");
5
6
  const status_fields_js_1 = require("./status-fields.js");
6
7
  const special_use_js_1 = require("../special-use.js");
7
8
  /**
@@ -140,6 +141,8 @@ async function list(connection, reference, mailbox, options) {
140
141
  // namespace prefix was used (INBOX may live outside the namespace).
141
142
  let runList = async (reference, mailbox, returnArgs) => {
142
143
  const cmdArgs = [(0, tools_js_1.encodePath)(connection, reference), (0, tools_js_1.encodePath)(connection, mailbox)];
144
+ // Set once the entry limit is reached, fails the command when it completes
145
+ let overflow = null;
143
146
  if (returnArgs.length) {
144
147
  cmdArgs.push({ type: 'ATOM', value: 'RETURN' }, returnArgs);
145
148
  }
@@ -199,6 +202,14 @@ async function list(connection, reference, mailbox, options) {
199
202
  if (specialUseFlag && (!isNameSource(flagSource) || !entry.flags.has('\\NonExistent'))) {
200
203
  addSpecialUseMatch(entry, specialUseFlag, flagSource);
201
204
  }
205
+ if (entries.length >= tools_js_1.MAX_COLLECTED_RESPONSES) {
206
+ // The entries are the server's to send and the memory holding them
207
+ // is this process's, see MAX_COLLECTED_RESPONSES. A throw from an
208
+ // untagged handler is only logged, so the error is raised below,
209
+ // once the command completes
210
+ overflow = overflow || (0, errors_js_1.createImapError)(`LIST returned more than ${tools_js_1.MAX_COLLECTED_RESPONSES} mailboxes`, 'TooManyResponses');
211
+ return;
212
+ }
202
213
  entries.push(entry);
203
214
  },
204
215
  // Inline STATUS response from LIST-STATUS extension (RFC 5819).
@@ -218,6 +229,9 @@ async function list(connection, reference, mailbox, options) {
218
229
  }
219
230
  });
220
231
  response.next();
232
+ if (overflow) {
233
+ throw overflow;
234
+ }
221
235
  };
222
236
  let normalizedReference = (0, tools_js_1.normalizePath)(connection, reference || '');
223
237
  let normalizedMailbox = (0, tools_js_1.normalizePath)(connection, mailbox || '', true);
@@ -35,6 +35,7 @@ export declare const ImapFlowErrorCode: {
35
35
  readonly LineTooLarge: "LineTooLarge";
36
36
  readonly LiteralTooLarge: "LiteralTooLarge";
37
37
  readonly ResponseTooLarge: "ResponseTooLarge";
38
+ readonly TooManyResponses: "TooManyResponses";
38
39
  readonly InvalidStringValue: "InvalidStringValue";
39
40
  readonly InvalidTokenValue: "InvalidTokenValue";
40
41
  readonly InvalidTextValue: "InvalidTextValue";
@@ -43,6 +43,8 @@ exports.ImapFlowErrorCode = {
43
43
  LineTooLarge: 'LineTooLarge',
44
44
  LiteralTooLarge: 'LiteralTooLarge',
45
45
  ResponseTooLarge: 'ResponseTooLarge',
46
+ // one command received more untagged responses than are kept, see MAX_COLLECTED_RESPONSES
47
+ TooManyResponses: 'TooManyResponses',
46
48
  // invalid values in a command
47
49
  InvalidStringValue: 'InvalidStringValue',
48
50
  InvalidTokenValue: 'InvalidTokenValue',
@@ -87,6 +87,9 @@ async function parser(command, options) {
87
87
  if (parserInstance.remainder.trim().length) {
88
88
  await parserInstance.getSpace();
89
89
  response.attributes = await parserInstance.getAttributes();
90
+ if (parserInstance.truncatedAttributes && parserInstance.truncatedAttributes.length) {
91
+ response.truncatedAttributes = parserInstance.truncatedAttributes;
92
+ }
90
93
  }
91
94
  if (parserInstance.humanReadable) {
92
95
  response.attributes = (response.attributes || []).concat({
@@ -12,6 +12,8 @@ export declare class ParserInstance {
12
12
  tag?: string | undefined;
13
13
  command?: string | undefined;
14
14
  humanReadable?: string | undefined;
15
+ /** The keys of the FETCH data items the token parser dropped for exceeding the nesting limit */
16
+ truncatedAttributes?: string[] | undefined;
15
17
  /**
16
18
  * Creates a new ParserInstance for parsing an IMAP response line.
17
19
  *
@@ -217,7 +217,9 @@ class ParserInstance {
217
217
  });
218
218
  }
219
219
  const tokenParser = new token_parser_js_1.TokenParser(this, this.pos, this.remainder, this.options);
220
- return await tokenParser.getAttributes();
220
+ let attributes = await tokenParser.getAttributes();
221
+ this.truncatedAttributes = tokenParser.truncatedAttributes;
222
+ return attributes;
221
223
  }
222
224
  }
223
225
  exports.ParserInstance = ParserInstance;
@@ -22,6 +22,8 @@ export interface TokenNode {
22
22
  started?: boolean | undefined;
23
23
  chBuffer?: Buffer | undefined;
24
24
  chPos?: number | undefined;
25
+ /** Set on a FETCH data item whose nesting exceeded MAX_NODE_DEPTH, see createNode() */
26
+ truncated?: boolean | undefined;
25
27
  }
26
28
  /**
27
29
  * The parent object a TokenParser reads the parsed command from
@@ -34,7 +36,8 @@ export interface TokenParserParent {
34
36
  * Handles all IMAP data types: atoms, quoted strings, literals (including literal8),
35
37
  * sequences, lists (parenthesized groups), sections (bracketed groups), and partial ranges.
36
38
  * Enforces a maximum nesting depth of {@link MAX_NODE_DEPTH} to prevent stack overflow
37
- * from malicious input.
39
+ * from malicious input: a FETCH data item that nests deeper is replaced by NIL and listed in
40
+ * `truncatedAttributes`, any other input that nests deeper fails to parse.
38
41
  */
39
42
  export declare class TokenParser {
40
43
  str: string;
@@ -45,6 +48,8 @@ export declare class TokenParser {
45
48
  currentNode: TokenNode;
46
49
  pos: number;
47
50
  state: number;
51
+ /** The keys of the FETCH data items that were dropped for exceeding MAX_NODE_DEPTH */
52
+ truncatedAttributes: string[];
48
53
  expectedLiteralType?: string | false | undefined;
49
54
  /**
50
55
  * Creates a new TokenParser.
@@ -88,9 +93,23 @@ export declare class TokenParser {
88
93
  * @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
89
94
  * @param startPos - The starting position of this node in the original input string.
90
95
  * @returns The newly created node with childNodes, type, value, and isClosed properties.
91
- * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH.
96
+ * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item.
92
97
  */
93
98
  createNode(parentNode?: TokenNode | undefined, startPos?: number | undefined): TokenNode;
99
+ /**
100
+ * Whether the line being parsed is a message data response (`* 12 FETCH (...)`): a numeric
101
+ * command with FETCH as its first attribute. Only those get the per-item nesting fallback.
102
+ *
103
+ * @returns True for an untagged FETCH response
104
+ */
105
+ isFetchResponse(): boolean;
106
+ /**
107
+ * Marks the FETCH data item a too deeply nested node belongs to as truncated and records
108
+ * its key (the item before it in the list, e.g. BODYSTRUCTURE) for the caller to report.
109
+ *
110
+ * @param node - The node at MAX_NODE_DEPTH whose child would exceed the limit
111
+ */
112
+ truncateFetchDataItem(node: TokenNode): void;
94
113
  /**
95
114
  * Processes the entire input string character by character using a state machine.
96
115
  * Transitions between states (NORMAL, ATOM, STRING, LITERAL, SEQUENCE, PARTIAL, TEXT)
@@ -15,14 +15,23 @@ const STATE_PARTIAL = 0x004;
15
15
  const STATE_SEQUENCE = 0x005;
16
16
  const STATE_STRING = 0x006;
17
17
  const RE_SINGLE_DIGIT = /^\d$/;
18
- // Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...))))))
19
- const MAX_NODE_DEPTH = 25;
18
+ // Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...)))))).
19
+ // The tokenizer itself is iterative; what recurses is the getAttributes() walk and the
20
+ // consumers of its output (the body structure parser, the compiler used for logging), and
21
+ // a hundred frames is nothing to any of them. The old bound of 25 was reached by a message
22
+ // with 22 nested multiparts, which an external sender can produce, and the whole FETCH row
23
+ // then failed to parse and was dropped silently.
24
+ const MAX_NODE_DEPTH = 100;
25
+ // The depth at which the data items of a FETCH response sit: TREE (0) > the item list (1) >
26
+ // a key or value (2). See createNode() for what happens when nesting overflows below one.
27
+ const FETCH_DATA_ITEM_DEPTH = 2;
20
28
  /**
21
29
  * Tokenizes an IMAP attribute string into a tree of typed nodes.
22
30
  * Handles all IMAP data types: atoms, quoted strings, literals (including literal8),
23
31
  * sequences, lists (parenthesized groups), sections (bracketed groups), and partial ranges.
24
32
  * Enforces a maximum nesting depth of {@link MAX_NODE_DEPTH} to prevent stack overflow
25
- * from malicious input.
33
+ * from malicious input: a FETCH data item that nests deeper is replaced by NIL and listed in
34
+ * `truncatedAttributes`, any other input that nests deeper fails to parse.
26
35
  */
27
36
  class TokenParser {
28
37
  /**
@@ -44,6 +53,7 @@ class TokenParser {
44
53
  // Same normalization and default as the streaming parser, so a direct user of this parser
45
54
  // gets the same bound (an explicit 0 means "reject any non-empty inline literal").
46
55
  this.maxLiteralSize = (0, limits_js_1.normalizeLimit)(this.options.maxLiteralSize, limits_js_1.MAX_LITERAL_SIZE);
56
+ this.truncatedAttributes = [];
47
57
  this.tree = this.currentNode = this.createNode();
48
58
  this.pos = startPos || 0;
49
59
  this.currentNode.type = 'TREE';
@@ -92,6 +102,16 @@ class TokenParser {
92
102
  throw this.parserError(`Unexpected end of input at position ${this.pos + this.str.length - 1} [E9]`, 'ParserError9', this.pos + this.str.length - 1);
93
103
  }
94
104
  let type = (node.type || '').toString().toUpperCase();
105
+ if (node.truncated) {
106
+ // The item nested deeper than MAX_NODE_DEPTH, see createNode(). Its subtree is
107
+ // not walked (that is what keeps this recursion bounded). A list is replaced by
108
+ // NIL so the data items keep pairing up as key and value; a section is attached
109
+ // to the item before it, so it is dropped whole.
110
+ if (type === 'LIST') {
111
+ branch.push(null);
112
+ }
113
+ return;
114
+ }
95
115
  switch (type) {
96
116
  case 'LITERAL':
97
117
  case 'STRING':
@@ -142,7 +162,7 @@ class TokenParser {
142
162
  * @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
143
163
  * @param startPos - The starting position of this node in the original input string.
144
164
  * @returns The newly created node with childNodes, type, value, and isClosed properties.
145
- * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH.
165
+ * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item.
146
166
  */
147
167
  createNode(parentNode, startPos) {
148
168
  let node = {
@@ -160,8 +180,20 @@ class TokenParser {
160
180
  node.depth = 0;
161
181
  }
162
182
  if (node.depth > MAX_NODE_DEPTH) {
163
- // A bounded prefix only, the error travels whole into logs and rejections
164
- throw (0, errors_js_1.createImapError)('Too much nesting in IMAP string', 'MAX_IMAP_NESTING_REACHED', { _imapStr: this.str.slice(0, limits_js_1.ERROR_CONTEXT_LENGTH) });
183
+ if (!this.isFetchResponse()) {
184
+ // A bounded prefix only, the error travels whole into logs and rejections
185
+ throw (0, errors_js_1.createImapError)('Too much nesting in IMAP string', 'MAX_IMAP_NESTING_REACHED', { _imapStr: this.str.slice(0, limits_js_1.ERROR_CONTEXT_LENGTH) });
186
+ }
187
+ // A FETCH row carries the data of one message, and the item nesting this deep (a
188
+ // BODYSTRUCTURE of a message with a hundred nested multiparts, which the sender of
189
+ // that message controls) is one of several. Failing the line would drop the whole
190
+ // row, and with it the message, from the FETCH result. The data item is marked
191
+ // instead, which getAttributes() turns into NIL, and the tokenizer carries on: it
192
+ // keeps no stack, so the depth costs it nothing. Marked on the first crossing only,
193
+ // every node below that one is already inside a marked item.
194
+ if (parentNode.depth === MAX_NODE_DEPTH) {
195
+ this.truncateFetchDataItem(parentNode);
196
+ }
165
197
  }
166
198
  if (typeof startPos === 'number') {
167
199
  node.startPos = startPos;
@@ -171,6 +203,44 @@ class TokenParser {
171
203
  }
172
204
  return node;
173
205
  }
206
+ /**
207
+ * Whether the line being parsed is a message data response (`* 12 FETCH (...)`): a numeric
208
+ * command with FETCH as its first attribute. Only those get the per-item nesting fallback.
209
+ *
210
+ * @returns True for an untagged FETCH response
211
+ */
212
+ isFetchResponse() {
213
+ let command = this.parent.command;
214
+ let first = this.tree.childNodes[0];
215
+ return (typeof command === 'string' &&
216
+ /^\d+$/.test(command) &&
217
+ !!first &&
218
+ first.type === 'ATOM' &&
219
+ typeof first.value === 'string' &&
220
+ first.value.toUpperCase() === 'FETCH');
221
+ }
222
+ /**
223
+ * Marks the FETCH data item a too deeply nested node belongs to as truncated and records
224
+ * its key (the item before it in the list, e.g. BODYSTRUCTURE) for the caller to report.
225
+ *
226
+ * @param node - The node at MAX_NODE_DEPTH whose child would exceed the limit
227
+ */
228
+ truncateFetchDataItem(node) {
229
+ let item = node;
230
+ while (item.depth > FETCH_DATA_ITEM_DEPTH && item.parentNode) {
231
+ item = item.parentNode;
232
+ }
233
+ if (item.truncated) {
234
+ return;
235
+ }
236
+ item.truncated = true;
237
+ // The item is the last child of the list while it is being parsed, so its key is the
238
+ // child before it. A key is an atom (BODYSTRUCTURE, ENVELOPE, BODY); anything else means
239
+ // the row itself is malformed, and the key is reported as unknown.
240
+ let siblings = item.parentNode ? item.parentNode.childNodes : [];
241
+ let key = siblings[siblings.length - 2];
242
+ this.truncatedAttributes.push(key && key.type === 'ATOM' && typeof key.value === 'string' ? key.value.toUpperCase() : '?');
243
+ }
174
244
  /**
175
245
  * Processes the entire input string character by character using a state machine.
176
246
  * Transitions between states (NORMAL, ATOM, STRING, LITERAL, SEQUENCE, PARTIAL, TEXT)
@@ -39,6 +39,11 @@ export interface ImapResponse {
39
39
  attributes?: ImapAttributeList | undefined;
40
40
  /** Number of leading NUL bytes stripped from the line, if any */
41
41
  nullBytesRemoved?: number | undefined;
42
+ /**
43
+ * Keys of the FETCH data items (e.g. BODYSTRUCTURE) that were replaced by NIL because they
44
+ * nested deeper than the parser's limit, if any
45
+ */
46
+ truncatedAttributes?: string[] | undefined;
42
47
  }
43
48
  /**
44
49
  * What the command compiler accepts as a node: parsed tokens and lists, and also bare
@@ -889,6 +889,16 @@ class ImapFlow extends node_events_1.EventEmitter {
889
889
  }
890
890
  return true;
891
891
  }
892
+ if (parsed.truncatedAttributes) {
893
+ // The row is kept with the item missing rather than dropped with the whole line: the
894
+ // nesting is the message sender's doing, and the message must still reach the caller
895
+ this.log.warn({
896
+ msg: 'FETCH data item exceeded the nesting limit and was dropped',
897
+ seq: parsed.command,
898
+ attributes: parsed.truncatedAttributes,
899
+ cid: this.id
900
+ });
901
+ }
892
902
  if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
893
903
  let payload = { response: parsed.command };
894
904
  if (parsed.attributes &&
@@ -1052,11 +1062,24 @@ class ImapFlow extends node_events_1.EventEmitter {
1052
1062
  async settleRequest(request, parsed, hasTrailingData) {
1053
1063
  switch ((parsed.command || '').toUpperCase()) {
1054
1064
  case 'OK':
1055
- case 'BYE':
1056
1065
  // hasTrailingData is forwarded so STARTTLS can detect a plaintext
1057
1066
  // injection (data buffered after the tagged OK, before the handshake).
1058
1067
  await this.resolveParked(request, parsed, hasTrailingData);
1059
1068
  break;
1069
+ case 'BYE': {
1070
+ // BYE is always untagged (RFC 9051 section 7.1.5), it is never the completion
1071
+ // result of a command. It used to resolve the command like an OK, so a server (or
1072
+ // a cleartext MITM) answering LOGIN with a tagged BYE marked the session
1073
+ // authenticated. The server says it is closing: the command fails as a lost
1074
+ // connection and the connection is closed, which for a LOGOUT is the outcome it
1075
+ // was after anyway.
1076
+ await this.serverBye(parsed);
1077
+ let err = this.createNoConnectionError(this.byeReason, { rejectedFrom: 'taggedBye', command: request.command });
1078
+ err.response = parsed;
1079
+ err.responseStatus = 'BYE';
1080
+ request.reject(err);
1081
+ break;
1082
+ }
1060
1083
  case 'NO':
1061
1084
  case 'BAD': {
1062
1085
  let txt = (0, tools_js_1.getTextValues)(parsed.attributes)
@@ -1538,6 +1561,13 @@ class ImapFlow extends node_events_1.EventEmitter {
1538
1561
  // STARTTLS explictly disabled by config
1539
1562
  return false;
1540
1563
  }
1564
+ if (this.state !== this.states.NOT_AUTHENTICATED) {
1565
+ // STARTTLS is only valid in the not-authenticated state (RFC 9051 section 6.2.1),
1566
+ // which a PREAUTH greeting skips, so there is no way left to protect this session.
1567
+ // Required STARTTLS fails here with that reason: the greeting arrived in cleartext,
1568
+ // and a MITM can answer with PREAUTH precisely to keep it that way.
1569
+ return this._failSTARTTLS('STARTTLS is not possible after a PREAUTH greeting');
1570
+ }
1541
1571
  if (!this.capabilities.has('STARTTLS')) {
1542
1572
  return this._failSTARTTLS();
1543
1573
  }
@@ -1982,7 +2012,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1982
2012
  let tags = [];
1983
2013
  let uids = false;
1984
2014
  // A malformed VANISHED can carry no attributes at all, and one carrying only the
1985
- // (EARLIER) tag leaves `uids` false - expandRange() handles that and yields nothing
2015
+ // (EARLIER) tag leaves `uids` false - iterateRange() handles that and yields nothing
1986
2016
  if (!untagged.attributes || !untagged.attributes.length) {
1987
2017
  return;
1988
2018
  }
@@ -1993,14 +2023,16 @@ class ImapFlow extends node_events_1.EventEmitter {
1993
2023
  if (untagged.attributes[0] && typeof untagged.attributes[0].value === 'string') {
1994
2024
  uids = untagged.attributes[0].value;
1995
2025
  }
1996
- let uidList = (0, tools_js_1.expandRange)(uids);
1997
2026
  let earlier = tags.includes('EARLIER');
1998
2027
  // RFC 7162 section 3.2.10: unlike VANISHED (EARLIER), a plain VANISHED reports messages the
1999
2028
  // client knows about and decrements the message count like the same number of EXPUNGEs would
2000
2029
  if (!earlier) {
2001
- mailbox.exists = Math.max(0, mailbox.exists - uidList.length);
2030
+ mailbox.exists = Math.max(0, mailbox.exists - (0, tools_js_1.countRange)(uids));
2002
2031
  }
2003
- for (let uid of uidList) {
2032
+ // The set is walked one UID at a time rather than expanded first: it is the server's to
2033
+ // choose, and a single "1:16777216" is a few bytes on the wire but sixteen million array
2034
+ // entries expanded, for every such line the server cares to send
2035
+ for (let uid of (0, tools_js_1.iterateRange)(uids)) {
2004
2036
  let payload = {
2005
2037
  path: mailbox.path,
2006
2038
  uid,
@@ -1,3 +1,3 @@
1
1
  export declare const name = "imapflow";
2
- export declare const version = "2.5.0";
2
+ export declare const version = "2.5.2";
3
3
  export declare const homepage = "https://imapflow.com/";
@@ -3,5 +3,5 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.homepage = exports.version = exports.name = void 0;
5
5
  exports.name = "imapflow";
6
- exports.version = "2.5.0";
6
+ exports.version = "2.5.2";
7
7
  exports.homepage = "https://imapflow.com/";
@@ -5,6 +5,7 @@ import type { ImapAttributeList, ImapAttributeNode, ImapResponse } from './handl
5
5
  import type { FetchMessageObject, ListResponse, ListTreeResponse, MailboxObject, MessageEnvelopeObject, MessageStructureObject, ImapFlowEvents, StatusQuery } from './types.js';
6
6
  export { AuthenticationFailure } from './errors.js';
7
7
  export declare const EXPANDED_RANGE_LIMIT = 16777216;
8
+ export declare const MAX_COLLECTED_RESPONSES = 1000000;
8
9
  export declare const MAX_UINT32_DIGITS = 10;
9
10
  export declare const noop: () => void;
10
11
  /**
@@ -471,6 +472,26 @@ export declare function parseBigIntValue(value: unknown, maxDigits?: number): bi
471
472
  * @returns The parsed value, or false when it is not usable.
472
473
  */
473
474
  export declare function parseUintValue(value: unknown, maxDigits?: number): number | false;
475
+ /**
476
+ * Walks an IMAP sequence range string (e.g. "1:3,5,7:9") one number at a time, without
477
+ * materializing it. A single "1:16777216" is a few bytes on the wire but sixteen million
478
+ * entries expanded, so a consumer that only needs to visit each number (the VANISHED handler)
479
+ * iterates instead of calling expandRange().
480
+ *
481
+ * The same bound as expandRange() applies: at most EXPANDED_RANGE_LIMIT numbers in total are
482
+ * produced, over all entries of the set.
483
+ *
484
+ * @param range - IMAP sequence range string
485
+ * @returns The sequence numbers, in the order the set lists them
486
+ */
487
+ export declare function iterateRange(range: unknown): Generator<number, void, undefined>;
488
+ /**
489
+ * Counts the numbers iterateRange() produces for a sequence range string, without walking it.
490
+ *
491
+ * @param range - IMAP sequence range string
492
+ * @returns The number of entries, capped at EXPANDED_RANGE_LIMIT like the expansion itself
493
+ */
494
+ export declare function countRange(range: unknown): number;
474
495
  /**
475
496
  * Expands an IMAP sequence range string (e.g. "1:3,5,7:9") into an array of numbers.
476
497
  *