imapflow 2.5.8 → 2.6.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 (43) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +9 -0
  3. package/dist/cjs/commands/append.js +6 -3
  4. package/dist/cjs/commands/fetch.d.ts +2 -1
  5. package/dist/cjs/commands/fetch.js +8 -2
  6. package/dist/cjs/commands/idle.js +7 -0
  7. package/dist/cjs/commands/select.js +17 -6
  8. package/dist/cjs/commands/status.js +19 -5
  9. package/dist/cjs/download.js +3 -3
  10. package/dist/cjs/errors.d.ts +3 -1
  11. package/dist/cjs/errors.js +4 -1
  12. package/dist/cjs/handler/imap-parser.js +38 -6
  13. package/dist/cjs/handler/limits.d.ts +1 -0
  14. package/dist/cjs/handler/limits.js +8 -1
  15. package/dist/cjs/handler/token-parser.d.ts +8 -2
  16. package/dist/cjs/handler/token-parser.js +55 -16
  17. package/dist/cjs/handler/types.d.ts +5 -2
  18. package/dist/cjs/imap-flow.d.ts +38 -2
  19. package/dist/cjs/imap-flow.js +99 -15
  20. package/dist/cjs/package-info.d.ts +1 -1
  21. package/dist/cjs/package-info.js +1 -1
  22. package/dist/cjs/types.d.ts +51 -7
  23. package/dist/esm/commands/append.js +6 -3
  24. package/dist/esm/commands/fetch.d.ts +2 -1
  25. package/dist/esm/commands/fetch.js +8 -2
  26. package/dist/esm/commands/idle.js +7 -0
  27. package/dist/esm/commands/select.js +17 -6
  28. package/dist/esm/commands/status.js +19 -5
  29. package/dist/esm/download.js +3 -3
  30. package/dist/esm/errors.d.ts +3 -1
  31. package/dist/esm/errors.js +4 -1
  32. package/dist/esm/handler/imap-parser.js +38 -6
  33. package/dist/esm/handler/limits.d.ts +1 -0
  34. package/dist/esm/handler/limits.js +7 -0
  35. package/dist/esm/handler/token-parser.d.ts +8 -2
  36. package/dist/esm/handler/token-parser.js +56 -17
  37. package/dist/esm/handler/types.d.ts +5 -2
  38. package/dist/esm/imap-flow.d.ts +38 -2
  39. package/dist/esm/imap-flow.js +99 -15
  40. package/dist/esm/package-info.d.ts +1 -1
  41. package/dist/esm/package-info.js +1 -1
  42. package/dist/esm/types.d.ts +51 -7
  43. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.6.0](https://github.com/postalsys/imapflow/compare/v2.5.9...v2.6.0) (2026-10-21)
4
+
5
+
6
+ ### Features
7
+
8
+ * expose mailboxList, context, connectedAt and lastClientWrite on the client ([c054bd3](https://github.com/postalsys/imapflow/commit/c054bd33ab0068531c64d613718233d3a1eff9e6))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * emit the error event at most once per connection ([434e6eb](https://github.com/postalsys/imapflow/commit/434e6eb84021892703d02a63dc28da4a8bf086d6))
14
+ * let idle() wait for a free connection before starting IDLE ([4a3d7df](https://github.com/postalsys/imapflow/commit/4a3d7df2116321997a966e0856d7035ed2b60992))
15
+ * reject a mailbox lock when the processLocks pass handling it throws ([a207cd0](https://github.com/postalsys/imapflow/commit/a207cd093535025524ff6066aed092779b0ff569))
16
+ * reject fetch() without a selected mailbox instead of returning nothing ([78036b8](https://github.com/postalsys/imapflow/commit/78036b806b9f70e70a78fa2bc668e340e63297af))
17
+ * report UIDVALIDITY and HIGHESTMODSEQ changes found by the polling fallback ([0f4c306](https://github.com/postalsys/imapflow/commit/0f4c306259ffc9bb973d4cb54f790707c100cc94))
18
+ * require an exact LIST match before treating a failed SELECT mailbox as existing ([99a5db0](https://github.com/postalsys/imapflow/commit/99a5db09a02da721b230fd56bb7d1b240cbb2a0f))
19
+ * start IDLE right away again when idle() is called inside a mailbox lock ([018967d](https://github.com/postalsys/imapflow/commit/018967ded2d6e44801c4af8e211b10870e0f2f44))
20
+
21
+ ## [2.5.9](https://github.com/postalsys/imapflow/compare/v2.5.8...v2.5.9) (2026-10-20)
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * bound the parse cost of responses with many literals or tokens ([cb2124f](https://github.com/postalsys/imapflow/commit/cb2124f7a4db4a4c3ec0f15bffa6e8d6e81c2426))
27
+ * do not report APPENDUID UIDVALIDITY next to a looked up UID ([#448](https://github.com/postalsys/imapflow/issues/448)) ([b5aadd3](https://github.com/postalsys/imapflow/commit/b5aadd399599f600009bb4db8ce16fdc574d7ce6))
28
+
3
29
  ## [2.5.8](https://github.com/postalsys/imapflow/compare/v2.5.7...v2.5.8) (2026-10-19)
4
30
 
5
31
 
package/README.md CHANGED
@@ -128,6 +128,15 @@ main().catch(console.error);
128
128
 
129
129
  An `ImapFlow` instance holds a single connection and cannot reconnect. Once the connection has closed, or after `connect()` has been called once, create a new instance to connect again. Calling `connect()` a second time on the same instance throws an error with the code `InstanceReused`.
130
130
 
131
+ ### Connection state
132
+
133
+ Besides `mailbox`, `usable`, `authenticated` and `capabilities`, a client exposes:
134
+
135
+ - `client.context` - data of the application's own, never read by ImapFlow. It starts as the `context` option (`new ImapFlow({ ..., context: { account: 'a1' } })`) and can be replaced at any time. Its type is `unknown`, cast it to what you store there.
136
+ - `client.connectedAt` - when the TCP or TLS connection was established, in milliseconds since the epoch, `null` before that.
137
+ - `client.lastClientWrite` - when the client last wrote to the server (a command, a literal, the `DONE` that ends IDLE), in milliseconds since the epoch, `null` before the first write. Useful for staying ahead of server inactivity timers.
138
+ - `client.mailboxList` - with `verifyOnly` and `includeMailboxes`, the mailboxes listed before the connection logged out; `undefined` otherwise.
139
+
131
140
  See the [Quick Start guide](https://imapflow.com/docs/getting-started/quick-start) for more examples, including Gmail, Outlook, and Yahoo configuration.
132
141
 
133
142
  ## Documentation
@@ -144,11 +144,14 @@ async function append(connection, destination, content, flags, idate) {
144
144
  // message was already stored and a retrying caller would duplicate it.
145
145
  let uidValidity = (0, tools_js_1.parseBigIntValue)(section[1] && section[1].value, tools_js_1.MAX_UINT32_DIGITS);
146
146
  let uid = (0, tools_js_1.parseUintValue)(section[2] && section[2].value, tools_js_1.MAX_UINT32_DIGITS);
147
- if (uidValidity !== false) {
148
- map.uidValidity = uidValidity;
149
- }
147
+ // UIDVALIDITY is only reported next to the UID it was sent with: kept without one,
148
+ // it would end up paired with the UID the SEARCH fallback below guesses from the
149
+ // sequence number, and the pair would pass for a server-confirmed identity.
150
150
  if (uid) {
151
151
  map.uid = uid;
152
+ if (uidValidity) {
153
+ map.uidValidity = uidValidity;
154
+ }
152
155
  }
153
156
  }
154
157
  }
@@ -25,6 +25,7 @@ export interface FetchCommandResult {
25
25
  * @param range - Message sequence number or UID range
26
26
  * @param query - Fetch query specifying which data to retrieve (e.g., flags, envelope, bodyStructure, headers, source, bodyParts)
27
27
  * @param options - Fetch options
28
- * @returns Object with message count and list, or undefined if not in SELECTED state
28
+ * @returns Object with message count and list, or undefined when the range is empty
29
+ * @throws NoMailboxSelected when not in SELECTED state (NoConnection once the connection is gone)
29
30
  */
30
31
  export default function fetch(connection: ImapFlow, range: string, query: FetchQueryObject, options?: FetchCommandOptions | undefined): Promise<FetchCommandResult | undefined>;
@@ -10,11 +10,17 @@ const errors_js_1 = require("../errors.js");
10
10
  * @param range - Message sequence number or UID range
11
11
  * @param query - Fetch query specifying which data to retrieve (e.g., flags, envelope, bodyStructure, headers, source, bodyParts)
12
12
  * @param options - Fetch options
13
- * @returns Object with message count and list, or undefined if not in SELECTED state
13
+ * @returns Object with message count and list, or undefined when the range is empty
14
+ * @throws NoMailboxSelected when not in SELECTED state (NoConnection once the connection is gone)
14
15
  */
15
16
  async function fetch(connection, range, query, options) {
16
17
  let mailbox = (0, tools_js_1.getSelectedMailbox)(connection);
17
- if (!mailbox || !range) {
18
+ if (!mailbox) {
19
+ // queued behind a CLOSE or a failed SELECT: rejecting keeps fetch() from yielding
20
+ // nothing as if the mailbox were empty
21
+ throw connection.createNoMailboxError('FETCH');
22
+ }
23
+ if (!range) {
18
24
  // nothing to do here
19
25
  return;
20
26
  }
@@ -215,6 +215,7 @@ async function pollOnce(connection, session) {
215
215
  // HIGHESTMODSEQ is filtered out again unless the server advertises CONDSTORE, so a
216
216
  // CONDSTORE session keeps mailbox.highestModseq current without asking a plain
217
217
  // server for an item it does not know.
218
+ let polled = connection.mailbox;
218
219
  let status = await connection.runInternal('STATUS', path, {
219
220
  messages: true,
220
221
  uidNext: true,
@@ -225,6 +226,12 @@ async function pollOnce(connection, session) {
225
226
  if (!status) {
226
227
  throw (0, errors_js_1.createImapError)('STATUS poll failed', 'PollFailed');
227
228
  }
229
+ // STATUS can only tell that the UIDs changed, the selected view is stale then: select
230
+ // the mailbox again, which reports it as closed and opened anew with the new state
231
+ if (polled && connection.mailbox === polled && status.uidValidity && polled.uidValidity && status.uidValidity !== polled.uidValidity) {
232
+ connection.log.info({ msg: 'UIDVALIDITY changed, selecting the mailbox again', path, cid: connection.id });
233
+ await connection.runInternal('SELECT', path, { readOnly: session.selectCommand.command === 'EXAMINE' });
234
+ }
228
235
  break;
229
236
  }
230
237
  case 'NOOP':
@@ -214,8 +214,13 @@ async function select(connection, pathInput, options) {
214
214
  let currentMailbox = connection.mailbox;
215
215
  connection.mailbox = false;
216
216
  // Emit mailboxClose if we're switching from a different mailbox.
217
- // Re-selecting the same mailbox (e.g., for resync) does not trigger close/open.
218
- if (currentMailbox && currentMailbox.path !== path) {
217
+ // Re-selecting the same mailbox (e.g., for resync) does not trigger close/open, unless
218
+ // it came back with a new UIDVALIDITY: the old view and every UID in it are gone then,
219
+ // which is reported the way a fresh mailboxOpen() of another mailbox would be.
220
+ let reopened = !currentMailbox ||
221
+ currentMailbox.path !== path ||
222
+ (!!currentMailbox.uidValidity && !!map.uidValidity && currentMailbox.uidValidity !== map.uidValidity);
223
+ if (currentMailbox && reopened) {
219
224
  (0, tools_js_1.emitSafe)(connection, 'mailboxClose', currentMailbox);
220
225
  }
221
226
  connection.mailbox = map;
@@ -223,13 +228,19 @@ async function select(connection, pathInput, options) {
223
228
  // re-issues the SELECT to detect changes on servers without IDLE support).
224
229
  connection.currentSelectCommand = selectCommand;
225
230
  connection.state = connection.states.SELECTED;
226
- if (!currentMailbox || currentMailbox.path !== path) {
231
+ if (reopened || !currentMailbox) {
227
232
  (0, tools_js_1.emitSafe)(connection, 'mailboxOpen', connection.mailbox);
228
233
  }
229
- else if (typeof map.exists === 'number' && map.exists !== currentMailbox.exists) {
234
+ else {
230
235
  // A re-SELECT of the open mailbox (the SELECT polling fallback) gets its EXISTS here
231
- // instead of in the global handler, so it is reported the same way
232
- (0, tools_js_1.emitSafe)(connection, 'exists', { path, count: map.exists, prevCount: currentMailbox.exists });
236
+ // instead of in the global handler, so it is reported the same way. Flag changes are
237
+ // not reported by SELECT, a new HIGHESTMODSEQ is the cue to resync them.
238
+ if (typeof map.exists === 'number' && map.exists !== currentMailbox.exists) {
239
+ (0, tools_js_1.emitSafe)(connection, 'exists', { path, count: map.exists, prevCount: currentMailbox.exists });
240
+ }
241
+ if (map.highestModseq && map.highestModseq !== currentMailbox.highestModseq) {
242
+ (0, tools_js_1.emitSafe)(connection, 'highestModseq', { path, highestModseq: map.highestModseq, prevHighestModseq: currentMailbox.highestModseq });
243
+ }
233
244
  }
234
245
  response.next();
235
246
  return map;
@@ -17,8 +17,13 @@ const MAILBOX_UPDATERS = {
17
17
  uidNext: (value, mailbox) => {
18
18
  mailbox.uidNext = value;
19
19
  },
20
- highestModseq: (value, mailbox) => {
21
- mailbox.highestModseq = value;
20
+ highestModseq: (value, mailbox, connection, path) => {
21
+ let prevHighestModseq = mailbox.highestModseq;
22
+ if (prevHighestModseq !== value) {
23
+ mailbox.highestModseq = value;
24
+ // the changes behind it are not reported by STATUS, the event is the cue to resync
25
+ (0, tools_js_1.emitSafe)(connection, 'highestModseq', { path, highestModseq: value, prevHighestModseq });
26
+ }
22
27
  }
23
28
  };
24
29
  /**
@@ -68,13 +73,22 @@ async function status(connection, path, query) {
68
73
  if (!list) {
69
74
  return;
70
75
  }
76
+ let updates = [];
71
77
  (0, status_fields_js_1.parseStatusList)(list, (key, value) => {
72
78
  map[key] = value;
73
- let updater = MAILBOX_UPDATERS[key];
74
- if (currentMailbox && updater) {
75
- updater(value, currentMailbox, connection, path);
79
+ if (currentMailbox && MAILBOX_UPDATERS[key]) {
80
+ updates.push([key, value]);
76
81
  }
77
82
  });
83
+ // Under a new UIDVALIDITY the counts describe a mailbox the selected view no
84
+ // longer matches, so they are not applied to it; the STATUS poll selects the
85
+ // mailbox again instead, which reports it as opened anew
86
+ if (currentMailbox && map.uidValidity && currentMailbox.uidValidity && map.uidValidity !== currentMailbox.uidValidity) {
87
+ return;
88
+ }
89
+ for (let [key, value] of updates) {
90
+ MAILBOX_UPDATERS[key](value, currentMailbox, connection, path);
91
+ }
78
92
  }
79
93
  }
80
94
  });
@@ -236,9 +236,9 @@ async function downloadMessage(client, range, part, options) {
236
236
  };
237
237
  let response = await fetchExpected(client, range, query, downloadOptions, expected);
238
238
  if (!response) {
239
- // An empty answer also comes from a connection that closed while the consumer was
240
- // paused between chunks (fetchOne() finds no mailbox then), and that is not a
241
- // missing message: a caller that retries would skip it
239
+ // An empty answer from a connection that closed while the consumer was paused
240
+ // between chunks is not a missing message: a caller that retries would skip it
241
+ // (fetchOne() itself rejects with NoConnection when it finds no mailbox then)
242
242
  if (!client.usable) {
243
243
  throw client.createNoConnectionError(false, { rejectedFrom: 'download' });
244
244
  }
@@ -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 TooManyTokens: "TooManyTokens";
38
39
  readonly TooManyResponses: "TooManyResponses";
39
40
  readonly InvalidStringValue: "InvalidStringValue";
40
41
  readonly InvalidTokenValue: "InvalidTokenValue";
@@ -50,6 +51,7 @@ export declare const ImapFlowErrorCode: {
50
51
  readonly ERR_INVALID_URL: "ERR_INVALID_URL";
51
52
  readonly UnsupportedTransportOption: "UnsupportedTransportOption";
52
53
  readonly InstanceReused: "InstanceReused";
54
+ readonly NoMailboxSelected: "NoMailboxSelected";
53
55
  };
54
56
  /** One of the {@link ImapFlowErrorCode} values */
55
57
  export type ImapFlowErrorCode = (typeof ImapFlowErrorCode)[keyof typeof ImapFlowErrorCode];
@@ -117,7 +119,7 @@ export interface ImapFlowError extends Error {
117
119
  lineLength?: number | undefined;
118
120
  /** The size of a rejected response */
119
121
  responseSize?: number | undefined;
120
- /** The bound that was exceeded */
122
+ /** The bound that was exceeded (bytes, or tokens for TooManyTokens) */
121
123
  maxSize?: number | undefined;
122
124
  /** OAuth error details from the server, for XOAUTH2 authentication failures */
123
125
  oauthError?: any;
@@ -43,6 +43,7 @@ exports.ImapFlowErrorCode = {
43
43
  LineTooLarge: 'LineTooLarge',
44
44
  LiteralTooLarge: 'LiteralTooLarge',
45
45
  ResponseTooLarge: 'ResponseTooLarge',
46
+ TooManyTokens: 'TooManyTokens',
46
47
  // one command received more untagged responses than are kept, see MAX_COLLECTED_RESPONSES
47
48
  TooManyResponses: 'TooManyResponses',
48
49
  // invalid values in a command
@@ -63,7 +64,9 @@ exports.ImapFlowErrorCode = {
63
64
  // the transport option
64
65
  UnsupportedTransportOption: 'UnsupportedTransportOption',
65
66
  // API misuse
66
- InstanceReused: 'InstanceReused'
67
+ InstanceReused: 'InstanceReused',
68
+ // a message command (FETCH) was called while no mailbox was selected
69
+ NoMailboxSelected: 'NoMailboxSelected'
67
70
  };
68
71
  /**
69
72
  * Error subclass thrown when IMAP authentication fails.
@@ -9,8 +9,8 @@ const parser_instance_js_1 = require("./parser-instance.js");
9
9
  const errors_js_1 = require("../errors.js");
10
10
  const limits_js_1 = require("./limits.js");
11
11
  // The codes the parser raises on purpose: the numbered ParserErrorN codes, ParserErrorExchange,
12
- // the nesting limit and the inline literal size limit
13
- const PARSER_ERROR_CODES = /^(?:ParserError\d+|ParserErrorExchange|MAX_IMAP_NESTING_REACHED|LiteralTooLarge)$/;
12
+ // the nesting limit, the inline literal size limit and the token limit
13
+ const PARSER_ERROR_CODES = /^(?:ParserError\d+|ParserErrorExchange|MAX_IMAP_NESTING_REACHED|LiteralTooLarge|TooManyTokens)$/;
14
14
  /**
15
15
  * Anything the parser throws other than one of its own coded errors - a TypeError from a parser
16
16
  * bug, a RangeError from runaway recursion or a bad Buffer offset (which carries a Node code), a
@@ -81,6 +81,39 @@ function readSearchNumbers(str) {
81
81
  numbers.push(value);
82
82
  return numbers;
83
83
  }
84
+ // The CONDSTORE list that ends a SEARCH answer to a MODSEQ query (RFC 7162 section 3.1.5)
85
+ const RE_SEARCH_MODSEQ = /^ \(MODSEQ (\d{1,19})\) *$/i;
86
+ /**
87
+ * Reads an untagged SEARCH response without the tokenizer, see readSearchNumbers(): the numbers,
88
+ * and the MODSEQ value of a trailing CONDSTORE list. A MODSEQ query on a large mailbox matches as
89
+ * many messages as any other, so its answer must not fall back to the tokenizer either.
90
+ *
91
+ * @param str - The rest of the line after the command
92
+ * @returns The numbers, with the MODSEQ list as the attributes the tokenizer would have made of
93
+ * it, or null when the line is left to the tokenizer
94
+ */
95
+ function readSearchResponse(str) {
96
+ // the last character tells whether there is a list to look for, without scanning the line
97
+ if (!str.trimEnd().endsWith(')')) {
98
+ const numbers = readSearchNumbers(str);
99
+ return numbers && { numbers };
100
+ }
101
+ const open = str.lastIndexOf(' (');
102
+ const match = open < 0 ? null : RE_SEARCH_MODSEQ.exec(str.slice(open));
103
+ if (!match) {
104
+ return null;
105
+ }
106
+ const numbers = readSearchNumbers(str.slice(0, open));
107
+ return (numbers && {
108
+ numbers,
109
+ attributes: [
110
+ [
111
+ { type: 'ATOM', value: 'MODSEQ' },
112
+ { type: 'ATOM', value: match[1] }
113
+ ]
114
+ ]
115
+ });
116
+ }
84
117
  /**
85
118
  * Parses a raw IMAP command or response buffer into a structured object.
86
119
  * Handles edge cases such as null-byte-padded responses from buggy servers and
@@ -136,10 +169,9 @@ async function parser(command, options) {
136
169
  response.command += ' ' + (await parserInstance.getElement(imap_formal_syntax_js_1.default.command()));
137
170
  }
138
171
  if (response.tag === '*' && (response.command || '').toUpperCase() === 'SEARCH') {
139
- const numbers = readSearchNumbers(parserInstance.remainder);
140
- if (numbers) {
141
- response.numbers = numbers;
142
- return response;
172
+ const search = readSearchResponse(parserInstance.remainder);
173
+ if (search) {
174
+ return Object.assign(response, search);
143
175
  }
144
176
  }
145
177
  if (parserInstance.remainder.trim().length) {
@@ -2,6 +2,7 @@ import { type ImapFlowError } from '../errors.js';
2
2
  export declare const MAX_LITERAL_SIZE: number;
3
3
  export declare const MAX_LINE_SIZE: number;
4
4
  export declare const MAX_RESPONSE_SIZE: number;
5
+ export declare const MAX_RESPONSE_TOKENS = 1000000;
5
6
  export declare const ERROR_CONTEXT_LENGTH = 1024;
6
7
  /**
7
8
  * The input a parse error carries: a bounded prefix with the full length.
@@ -3,7 +3,7 @@
3
3
  // (ImapStream) and the standalone token parser cannot drift apart, and so the documented
4
4
  // defaults in the ImapFlowOptions type describe both paths.
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.createLiteralTooLargeError = exports.normalizeLimit = exports.boundedInput = exports.ERROR_CONTEXT_LENGTH = exports.MAX_RESPONSE_SIZE = exports.MAX_LINE_SIZE = exports.MAX_LITERAL_SIZE = void 0;
6
+ exports.createLiteralTooLargeError = exports.normalizeLimit = exports.boundedInput = exports.ERROR_CONTEXT_LENGTH = exports.MAX_RESPONSE_TOKENS = exports.MAX_RESPONSE_SIZE = exports.MAX_LINE_SIZE = exports.MAX_LITERAL_SIZE = void 0;
7
7
  const errors_js_1 = require("../errors.js");
8
8
  // Maximum allowed literal size: 1GB (1073741824 bytes)
9
9
  exports.MAX_LITERAL_SIZE = 1024 * 1024 * 1024;
@@ -21,6 +21,13 @@ exports.MAX_LINE_SIZE = exports.MAX_LITERAL_SIZE;
21
21
  // of exactly the maximum permitted size impossible to receive. Configuring both limits calls for
22
22
  // the same headroom - set maxResponseSize above maxLiteralSize, not equal to it.
23
23
  exports.MAX_RESPONSE_SIZE = 2 * exports.MAX_LITERAL_SIZE;
24
+ // Default maximum number of tokens (atoms, strings, literals, lists and the other parse tree
25
+ // nodes) in a single response. The byte limits bound what a server sends, not what parsing it
26
+ // costs: every token becomes a tree node and an attribute object, around a hundred bytes of heap
27
+ // for a two-byte "1 ", so a line of many small tokens costs fifty times its size. A legitimate
28
+ // response stays far below this (the long one, a SEARCH answer of plain numbers, is read without
29
+ // the tokenizer, see parser()), so the bound only stops a server that floods one response.
30
+ exports.MAX_RESPONSE_TOKENS = 1000000;
24
31
  // How much of the offending input a parse error carries along. The error travels whole into log
25
32
  // entries (pino copies every property of a logged error) and into the rejection of the command
26
33
  // the line belonged to, and the line can be as long as the configured line cap.
@@ -7,7 +7,8 @@ import type { ParserInstance } from './parser-instance.js';
7
7
  * (ATOM, string, LITERAL, SEQUENCE, LIST, SECTION, PARTIAL).
8
8
  */
9
9
  export interface TokenNode {
10
- childNodes: TokenNode[];
10
+ /** Created with the first child, a leaf token does not carry an empty array */
11
+ childNodes?: TokenNode[] | undefined;
11
12
  type: string | false;
12
13
  value: string | Buffer;
13
14
  isClosed: boolean;
@@ -44,6 +45,9 @@ export declare class TokenParser {
44
45
  options: ParserOptions;
45
46
  parent: TokenParserParent | ParserInstance;
46
47
  maxLiteralSize: number;
48
+ maxResponseTokens: number;
49
+ /** Nodes created so far, checked against maxResponseTokens */
50
+ tokenCount: number;
47
51
  tree: TokenNode;
48
52
  currentNode: TokenNode;
49
53
  pos: number;
@@ -62,6 +66,7 @@ export declare class TokenParser {
62
66
  * @param options.literals - Pre-parsed literal values from the input stream.
63
67
  * @param options.maxLiteralSize - Maximum size (in bytes) of a literal parsed inline
64
68
  * from the input, i.e. when no pre-parsed literal buffers were supplied. Defaults to 1GB.
69
+ * @param options.maxResponseTokens - Maximum number of parse tree nodes, see MAX_RESPONSE_TOKENS.
65
70
  */
66
71
  constructor(parent: TokenParserParent | ParserInstance, startPos?: number | undefined, str?: string | null | undefined, options?: ParserOptions | undefined);
67
72
  /**
@@ -93,7 +98,8 @@ export declare class TokenParser {
93
98
  * @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
94
99
  * @param startPos - The starting position of this node in the original input string.
95
100
  * @returns The newly created node with childNodes, type, value, and isClosed properties.
96
- * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item.
101
+ * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item, or
102
+ * the response has more than maxResponseTokens nodes.
97
103
  */
98
104
  createNode(parentNode?: TokenNode | undefined, startPos?: number | undefined): TokenNode;
99
105
  /**
@@ -45,6 +45,7 @@ class TokenParser {
45
45
  * @param options.literals - Pre-parsed literal values from the input stream.
46
46
  * @param options.maxLiteralSize - Maximum size (in bytes) of a literal parsed inline
47
47
  * from the input, i.e. when no pre-parsed literal buffers were supplied. Defaults to 1GB.
48
+ * @param options.maxResponseTokens - Maximum number of parse tree nodes, see MAX_RESPONSE_TOKENS.
48
49
  */
49
50
  constructor(parent, startPos, str, options) {
50
51
  this.str = (str || '').toString();
@@ -53,6 +54,8 @@ class TokenParser {
53
54
  // Same normalization and default as the streaming parser, so a direct user of this parser
54
55
  // gets the same bound (an explicit 0 means "reject any non-empty inline literal").
55
56
  this.maxLiteralSize = (0, limits_js_1.normalizeLimit)(this.options.maxLiteralSize, limits_js_1.MAX_LITERAL_SIZE);
57
+ this.maxResponseTokens = (0, limits_js_1.normalizeLimit)(this.options.maxResponseTokens, limits_js_1.MAX_RESPONSE_TOKENS);
58
+ this.tokenCount = 0;
56
59
  this.truncatedAttributes = [];
57
60
  this.tree = this.currentNode = this.createNode();
58
61
  this.pos = startPos || 0;
@@ -89,7 +92,9 @@ class TokenParser {
89
92
  await this.processString();
90
93
  const attributes = [];
91
94
  let branch = attributes;
92
- let walk = async (node) => {
95
+ // Synchronous on purpose: the recursion is bounded by MAX_NODE_DEPTH, and an async walk
96
+ // allocated a promise per token, which a line of many small tokens multiplies
97
+ let walk = (node) => {
93
98
  let curBranch = branch;
94
99
  let elm;
95
100
  let partial;
@@ -146,12 +151,16 @@ class TokenParser {
146
151
  branch[branch.length - 1].partial = partial;
147
152
  break;
148
153
  }
149
- for (let childNode of node.childNodes) {
150
- await walk(childNode);
154
+ // Each child is released once it is converted, so the tree and the attributes built
155
+ // from it are not both held whole at the peak of a line of many tokens
156
+ let childNodes = node.childNodes;
157
+ for (let i = 0; childNodes && i < childNodes.length; i++) {
158
+ walk(childNodes[i]);
159
+ childNodes[i] = null;
151
160
  }
152
161
  branch = curBranch;
153
162
  };
154
- await walk(this.tree);
163
+ walk(this.tree);
155
164
  return attributes;
156
165
  }
157
166
  /**
@@ -162,11 +171,20 @@ class TokenParser {
162
171
  * @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
163
172
  * @param startPos - The starting position of this node in the original input string.
164
173
  * @returns The newly created node with childNodes, type, value, and isClosed properties.
165
- * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item.
174
+ * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item, or
175
+ * the response has more than maxResponseTokens nodes.
166
176
  */
167
177
  createNode(parentNode, startPos) {
178
+ // The root node is not a token of the response
179
+ if (parentNode && ++this.tokenCount > this.maxResponseTokens) {
180
+ throw (0, errors_js_1.createImapError)(`Response has more than ${this.maxResponseTokens} tokens`, 'TooManyTokens', {
181
+ maxSize: this.maxResponseTokens,
182
+ parserContext: { ...(0, limits_js_1.boundedInput)(this.str), pos: startPos }
183
+ });
184
+ }
168
185
  let node = {
169
- childNodes: [],
186
+ // declared up front, so a node keeps one shape when its first child arrives
187
+ childNodes: undefined,
170
188
  type: false,
171
189
  value: '',
172
190
  isClosed: true,
@@ -199,7 +217,7 @@ class TokenParser {
199
217
  node.startPos = startPos;
200
218
  }
201
219
  if (parentNode) {
202
- parentNode.childNodes.push(node);
220
+ (parentNode.childNodes ||= []).push(node);
203
221
  }
204
222
  return node;
205
223
  }
@@ -211,7 +229,7 @@ class TokenParser {
211
229
  */
212
230
  isFetchResponse() {
213
231
  let command = this.parent.command;
214
- let first = this.tree.childNodes[0];
232
+ let first = this.tree.childNodes?.[0];
215
233
  return (typeof command === 'string' &&
216
234
  /^\d+$/.test(command) &&
217
235
  !!first &&
@@ -237,7 +255,7 @@ class TokenParser {
237
255
  // The item is the last child of the list while it is being parsed, so its key is the
238
256
  // child before it. A key is an atom (BODYSTRUCTURE, ENVELOPE, BODY); anything else means
239
257
  // the row itself is malformed, and the key is reported as unknown.
240
- let siblings = item.parentNode ? item.parentNode.childNodes : [];
258
+ let siblings = item.parentNode?.childNodes || [];
241
259
  let key = siblings[siblings.length - 2];
242
260
  this.truncatedAttributes.push(key && key.type === 'ATOM' && typeof key.value === 'string' ? key.value.toUpperCase() : '?');
243
261
  }
@@ -265,13 +283,26 @@ class TokenParser {
265
283
  // character, which is quadratic in the token length, and the server decides that length:
266
284
  // a 600 KB ESEARCH result took seconds of event loop time
267
285
  let tokenLength = 0;
286
+ // Where the token starts in the input, and whether its value still is that run of the
287
+ // input. A token is read as a run of characters, so its value is sliced from the input
288
+ // instead of grown with +=: each += of a long token adds a cons string of some twenty
289
+ // bytes per character, which the server multiplies with a long token
290
+ let tokenStart = 0;
291
+ let tokenSliced = true;
292
+ // Where the quoted string being read starts, and whether it holds an escape
293
+ let stringStart = 0;
294
+ let stringEscaped = false;
268
295
  let tokenLast = '';
269
296
  let tokenPrev = '';
270
297
  let tokenDigitsOnly = true;
271
298
  let tokenHasStar = false;
272
299
  let tokenHasDot = false;
273
300
  const appendToToken = (c) => {
274
- this.currentNode.value += c;
301
+ if (!tokenLength) {
302
+ tokenStart = i;
303
+ }
304
+ tokenSliced = tokenSliced && i === tokenStart + tokenLength && this.str.charAt(i) === c;
305
+ this.currentNode.value = tokenSliced ? this.str.slice(tokenStart, i + 1) : this.currentNode.value + c;
275
306
  tokenLength++;
276
307
  tokenPrev = tokenLast;
277
308
  tokenLast = c;
@@ -295,6 +326,7 @@ class TokenParser {
295
326
  const startToken = (first) => {
296
327
  this.currentNode.value = '';
297
328
  tokenLength = 0;
329
+ tokenSliced = true;
298
330
  tokenLast = tokenPrev = '';
299
331
  tokenDigitsOnly = true;
300
332
  tokenHasStar = tokenHasDot = false;
@@ -305,11 +337,14 @@ class TokenParser {
305
337
  // ImapStream supplies one buffer per literal marker it framed, {0} included. A marker
306
338
  // without a buffer means the line and its literals do not match up, so fail with a parser
307
339
  // error instead of reading a missing buffer or shifting every later literal by one
340
+ // Read by index: shift() moves every remaining buffer, which made a response of many
341
+ // literals quadratic to parse
342
+ let literalIndex = 0;
308
343
  const takeLiteral = (literals) => {
309
- if (!literals.length) {
344
+ if (literalIndex >= literals.length) {
310
345
  throw this.parserError(`Literal without data at position ${this.pos + i} [E35]`, 'ParserError35', this.pos + i, chr);
311
346
  }
312
- return literals.shift();
347
+ return literals[literalIndex++];
313
348
  };
314
349
  // Any ATOM supported char starts a new Atom sequence, otherwise throw an error
315
350
  // Allow \ as the first char for atom to support system flags
@@ -336,6 +371,8 @@ class TokenParser {
336
371
  this.currentNode = this.createNode(this.currentNode, this.pos + i);
337
372
  this.currentNode.type = 'string';
338
373
  this.state = STATE_STRING;
374
+ stringStart = i + 1;
375
+ stringEscaped = false;
339
376
  this.currentNode.isClosed = false;
340
377
  break;
341
378
  // ( starts a new list
@@ -566,8 +603,11 @@ class TokenParser {
566
603
  appendToToken(chr);
567
604
  break;
568
605
  case STATE_STRING:
569
- // DQUOTE ends the string sequence
606
+ // DQUOTE ends the string sequence. Its value is taken from the input in one
607
+ // piece, for the same reason tokens are sliced, see appendToToken()
570
608
  if (chr === '"') {
609
+ let value = this.str.slice(stringStart, i);
610
+ this.currentNode.value = stringEscaped ? value.replace(/\\([\s\S])/g, '$1') : value;
571
611
  this.currentNode.endPos = this.pos + i;
572
612
  this.currentNode.isClosed = true;
573
613
  this.currentNode = this.currentNode.parentNode;
@@ -575,15 +615,14 @@ class TokenParser {
575
615
  checkSP();
576
616
  break;
577
617
  }
578
- // \ Escapes the following char
618
+ // \ Escapes the following char, which is skipped here so it can not end the string
579
619
  if (chr === '\\') {
580
620
  i++;
581
621
  if (i >= len) {
582
622
  throw this.parserError(`Unexpected end of input at position ${this.pos + i} [E18]`, 'ParserError18', this.pos + i);
583
623
  }
584
- chr = this.str.charAt(i);
624
+ stringEscaped = true;
585
625
  }
586
- this.currentNode.value += chr;
587
626
  break;
588
627
  case STATE_PARTIAL:
589
628
  if (chr === '>') {
@@ -38,8 +38,9 @@ export interface ImapResponse {
38
38
  /** Parsed attributes of the response */
39
39
  attributes?: ImapAttributeList | undefined;
40
40
  /**
41
- * The numbers of an untagged SEARCH response (`* SEARCH 2 5 9`), set instead of `attributes`
42
- * when the line holds nothing else, see parser()
41
+ * The numbers of an untagged SEARCH response (`* SEARCH 2 5 9`), set when the line holds
42
+ * nothing else but a CONDSTORE `(MODSEQ n)` list, which is then the only entry of
43
+ * `attributes`, see parser()
43
44
  */
44
45
  numbers?: number[] | undefined;
45
46
  /** Number of leading NUL bytes stripped from the line, if any */
@@ -79,6 +80,8 @@ export interface ParserOptions {
79
80
  literals?: Buffer[] | undefined;
80
81
  /** Maximum size in bytes of a literal parsed inline from the input */
81
82
  maxLiteralSize?: number | undefined;
83
+ /** Maximum number of tokens in the response, see MAX_RESPONSE_TOKENS */
84
+ maxResponseTokens?: number | undefined;
82
85
  }
83
86
  /**
84
87
  * One assembled response as pushed by ImapStream: the line payload, its literals and the