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
@@ -9,14 +9,23 @@ const STATE_PARTIAL = 0x004;
9
9
  const STATE_SEQUENCE = 0x005;
10
10
  const STATE_STRING = 0x006;
11
11
  const RE_SINGLE_DIGIT = /^\d$/;
12
- // Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...))))))
13
- const MAX_NODE_DEPTH = 25;
12
+ // Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...)))))).
13
+ // The tokenizer itself is iterative; what recurses is the getAttributes() walk and the
14
+ // consumers of its output (the body structure parser, the compiler used for logging), and
15
+ // a hundred frames is nothing to any of them. The old bound of 25 was reached by a message
16
+ // with 22 nested multiparts, which an external sender can produce, and the whole FETCH row
17
+ // then failed to parse and was dropped silently.
18
+ const MAX_NODE_DEPTH = 100;
19
+ // The depth at which the data items of a FETCH response sit: TREE (0) > the item list (1) >
20
+ // a key or value (2). See createNode() for what happens when nesting overflows below one.
21
+ const FETCH_DATA_ITEM_DEPTH = 2;
14
22
  /**
15
23
  * Tokenizes an IMAP attribute string into a tree of typed nodes.
16
24
  * Handles all IMAP data types: atoms, quoted strings, literals (including literal8),
17
25
  * sequences, lists (parenthesized groups), sections (bracketed groups), and partial ranges.
18
26
  * Enforces a maximum nesting depth of {@link MAX_NODE_DEPTH} to prevent stack overflow
19
- * from malicious input.
27
+ * from malicious input: a FETCH data item that nests deeper is replaced by NIL and listed in
28
+ * `truncatedAttributes`, any other input that nests deeper fails to parse.
20
29
  */
21
30
  export class TokenParser {
22
31
  /**
@@ -38,6 +47,7 @@ export class TokenParser {
38
47
  // Same normalization and default as the streaming parser, so a direct user of this parser
39
48
  // gets the same bound (an explicit 0 means "reject any non-empty inline literal").
40
49
  this.maxLiteralSize = normalizeLimit(this.options.maxLiteralSize, MAX_LITERAL_SIZE);
50
+ this.truncatedAttributes = [];
41
51
  this.tree = this.currentNode = this.createNode();
42
52
  this.pos = startPos || 0;
43
53
  this.currentNode.type = 'TREE';
@@ -86,6 +96,16 @@ export class TokenParser {
86
96
  throw this.parserError(`Unexpected end of input at position ${this.pos + this.str.length - 1} [E9]`, 'ParserError9', this.pos + this.str.length - 1);
87
97
  }
88
98
  let type = (node.type || '').toString().toUpperCase();
99
+ if (node.truncated) {
100
+ // The item nested deeper than MAX_NODE_DEPTH, see createNode(). Its subtree is
101
+ // not walked (that is what keeps this recursion bounded). A list is replaced by
102
+ // NIL so the data items keep pairing up as key and value; a section is attached
103
+ // to the item before it, so it is dropped whole.
104
+ if (type === 'LIST') {
105
+ branch.push(null);
106
+ }
107
+ return;
108
+ }
89
109
  switch (type) {
90
110
  case 'LITERAL':
91
111
  case 'STRING':
@@ -136,7 +156,7 @@ export class TokenParser {
136
156
  * @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
137
157
  * @param startPos - The starting position of this node in the original input string.
138
158
  * @returns The newly created node with childNodes, type, value, and isClosed properties.
139
- * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH.
159
+ * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item.
140
160
  */
141
161
  createNode(parentNode, startPos) {
142
162
  let node = {
@@ -154,8 +174,20 @@ export class TokenParser {
154
174
  node.depth = 0;
155
175
  }
156
176
  if (node.depth > MAX_NODE_DEPTH) {
157
- // A bounded prefix only, the error travels whole into logs and rejections
158
- throw createImapError('Too much nesting in IMAP string', 'MAX_IMAP_NESTING_REACHED', { _imapStr: this.str.slice(0, ERROR_CONTEXT_LENGTH) });
177
+ if (!this.isFetchResponse()) {
178
+ // A bounded prefix only, the error travels whole into logs and rejections
179
+ throw createImapError('Too much nesting in IMAP string', 'MAX_IMAP_NESTING_REACHED', { _imapStr: this.str.slice(0, ERROR_CONTEXT_LENGTH) });
180
+ }
181
+ // A FETCH row carries the data of one message, and the item nesting this deep (a
182
+ // BODYSTRUCTURE of a message with a hundred nested multiparts, which the sender of
183
+ // that message controls) is one of several. Failing the line would drop the whole
184
+ // row, and with it the message, from the FETCH result. The data item is marked
185
+ // instead, which getAttributes() turns into NIL, and the tokenizer carries on: it
186
+ // keeps no stack, so the depth costs it nothing. Marked on the first crossing only,
187
+ // every node below that one is already inside a marked item.
188
+ if (parentNode.depth === MAX_NODE_DEPTH) {
189
+ this.truncateFetchDataItem(parentNode);
190
+ }
159
191
  }
160
192
  if (typeof startPos === 'number') {
161
193
  node.startPos = startPos;
@@ -165,6 +197,44 @@ export class TokenParser {
165
197
  }
166
198
  return node;
167
199
  }
200
+ /**
201
+ * Whether the line being parsed is a message data response (`* 12 FETCH (...)`): a numeric
202
+ * command with FETCH as its first attribute. Only those get the per-item nesting fallback.
203
+ *
204
+ * @returns True for an untagged FETCH response
205
+ */
206
+ isFetchResponse() {
207
+ let command = this.parent.command;
208
+ let first = this.tree.childNodes[0];
209
+ return (typeof command === 'string' &&
210
+ /^\d+$/.test(command) &&
211
+ !!first &&
212
+ first.type === 'ATOM' &&
213
+ typeof first.value === 'string' &&
214
+ first.value.toUpperCase() === 'FETCH');
215
+ }
216
+ /**
217
+ * Marks the FETCH data item a too deeply nested node belongs to as truncated and records
218
+ * its key (the item before it in the list, e.g. BODYSTRUCTURE) for the caller to report.
219
+ *
220
+ * @param node - The node at MAX_NODE_DEPTH whose child would exceed the limit
221
+ */
222
+ truncateFetchDataItem(node) {
223
+ let item = node;
224
+ while (item.depth > FETCH_DATA_ITEM_DEPTH && item.parentNode) {
225
+ item = item.parentNode;
226
+ }
227
+ if (item.truncated) {
228
+ return;
229
+ }
230
+ item.truncated = true;
231
+ // The item is the last child of the list while it is being parsed, so its key is the
232
+ // child before it. A key is an atom (BODYSTRUCTURE, ENVELOPE, BODY); anything else means
233
+ // the row itself is malformed, and the key is reported as unknown.
234
+ let siblings = item.parentNode ? item.parentNode.childNodes : [];
235
+ let key = siblings[siblings.length - 2];
236
+ this.truncatedAttributes.push(key && key.type === 'ATOM' && typeof key.value === 'string' ? key.value.toUpperCase() : '?');
237
+ }
168
238
  /**
169
239
  * Processes the entire input string character by character using a state machine.
170
240
  * 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
@@ -15,7 +15,7 @@ import { ConnectionDeadline } from './connection-deadline.js';
15
15
  import { downloadMessage, downloadMessageParts } from './download.js';
16
16
  import { AuthenticationFailure, createImapError } from './errors.js';
17
17
  import imapCommands from './imap-commands.js';
18
- import { comparePaths, updateCapabilities, getFolderTree, formatMessageResponse, mergeFetchRows, packMessageRange, normalizePath, expandRange, getColorFlags, hasCapability, isRev2Active, logConnectionError, unrefTimer, clearTimer, parseUintValue, getStringList, getTextValues, emitSafe, buildConnectionError, guardedPromise, guardedReject, isIpLiteral, MAX_UINT32_DIGITS } from './tools.js';
18
+ import { comparePaths, updateCapabilities, getFolderTree, formatMessageResponse, mergeFetchRows, packMessageRange, normalizePath, iterateRange, countRange, getColorFlags, hasCapability, isRev2Active, logConnectionError, unrefTimer, clearTimer, parseUintValue, getStringList, getTextValues, emitSafe, buildConnectionError, guardedPromise, guardedReject, isIpLiteral, MAX_UINT32_DIGITS } from './tools.js';
19
19
  export { AuthenticationFailure, ImapFlowErrorCode } from './errors.js';
20
20
  export { createSocketsTransport } from './sockets-transport.js';
21
21
  export { createStreamsTransport } from './streams-transport.js';
@@ -846,6 +846,16 @@ export class ImapFlow extends EventEmitter {
846
846
  }
847
847
  return true;
848
848
  }
849
+ if (parsed.truncatedAttributes) {
850
+ // The row is kept with the item missing rather than dropped with the whole line: the
851
+ // nesting is the message sender's doing, and the message must still reach the caller
852
+ this.log.warn({
853
+ msg: 'FETCH data item exceeded the nesting limit and was dropped',
854
+ seq: parsed.command,
855
+ attributes: parsed.truncatedAttributes,
856
+ cid: this.id
857
+ });
858
+ }
849
859
  if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
850
860
  let payload = { response: parsed.command };
851
861
  if (parsed.attributes &&
@@ -1009,11 +1019,24 @@ export class ImapFlow extends EventEmitter {
1009
1019
  async settleRequest(request, parsed, hasTrailingData) {
1010
1020
  switch ((parsed.command || '').toUpperCase()) {
1011
1021
  case 'OK':
1012
- case 'BYE':
1013
1022
  // hasTrailingData is forwarded so STARTTLS can detect a plaintext
1014
1023
  // injection (data buffered after the tagged OK, before the handshake).
1015
1024
  await this.resolveParked(request, parsed, hasTrailingData);
1016
1025
  break;
1026
+ case 'BYE': {
1027
+ // BYE is always untagged (RFC 9051 section 7.1.5), it is never the completion
1028
+ // result of a command. It used to resolve the command like an OK, so a server (or
1029
+ // a cleartext MITM) answering LOGIN with a tagged BYE marked the session
1030
+ // authenticated. The server says it is closing: the command fails as a lost
1031
+ // connection and the connection is closed, which for a LOGOUT is the outcome it
1032
+ // was after anyway.
1033
+ await this.serverBye(parsed);
1034
+ let err = this.createNoConnectionError(this.byeReason, { rejectedFrom: 'taggedBye', command: request.command });
1035
+ err.response = parsed;
1036
+ err.responseStatus = 'BYE';
1037
+ request.reject(err);
1038
+ break;
1039
+ }
1017
1040
  case 'NO':
1018
1041
  case 'BAD': {
1019
1042
  let txt = getTextValues(parsed.attributes)
@@ -1495,6 +1518,13 @@ export class ImapFlow extends EventEmitter {
1495
1518
  // STARTTLS explictly disabled by config
1496
1519
  return false;
1497
1520
  }
1521
+ if (this.state !== this.states.NOT_AUTHENTICATED) {
1522
+ // STARTTLS is only valid in the not-authenticated state (RFC 9051 section 6.2.1),
1523
+ // which a PREAUTH greeting skips, so there is no way left to protect this session.
1524
+ // Required STARTTLS fails here with that reason: the greeting arrived in cleartext,
1525
+ // and a MITM can answer with PREAUTH precisely to keep it that way.
1526
+ return this._failSTARTTLS('STARTTLS is not possible after a PREAUTH greeting');
1527
+ }
1498
1528
  if (!this.capabilities.has('STARTTLS')) {
1499
1529
  return this._failSTARTTLS();
1500
1530
  }
@@ -1939,7 +1969,7 @@ export class ImapFlow extends EventEmitter {
1939
1969
  let tags = [];
1940
1970
  let uids = false;
1941
1971
  // A malformed VANISHED can carry no attributes at all, and one carrying only the
1942
- // (EARLIER) tag leaves `uids` false - expandRange() handles that and yields nothing
1972
+ // (EARLIER) tag leaves `uids` false - iterateRange() handles that and yields nothing
1943
1973
  if (!untagged.attributes || !untagged.attributes.length) {
1944
1974
  return;
1945
1975
  }
@@ -1950,14 +1980,16 @@ export class ImapFlow extends EventEmitter {
1950
1980
  if (untagged.attributes[0] && typeof untagged.attributes[0].value === 'string') {
1951
1981
  uids = untagged.attributes[0].value;
1952
1982
  }
1953
- let uidList = expandRange(uids);
1954
1983
  let earlier = tags.includes('EARLIER');
1955
1984
  // RFC 7162 section 3.2.10: unlike VANISHED (EARLIER), a plain VANISHED reports messages the
1956
1985
  // client knows about and decrements the message count like the same number of EXPUNGEs would
1957
1986
  if (!earlier) {
1958
- mailbox.exists = Math.max(0, mailbox.exists - uidList.length);
1987
+ mailbox.exists = Math.max(0, mailbox.exists - countRange(uids));
1959
1988
  }
1960
- for (let uid of uidList) {
1989
+ // The set is walked one UID at a time rather than expanded first: it is the server's to
1990
+ // choose, and a single "1:16777216" is a few bytes on the wire but sixteen million array
1991
+ // entries expanded, for every such line the server cares to send
1992
+ for (let uid of iterateRange(uids)) {
1961
1993
  let payload = {
1962
1994
  path: mailbox.path,
1963
1995
  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/";
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/build.js from package.json. Do not edit by hand.
2
2
  export const name = "imapflow";
3
- export const version = "2.5.0";
3
+ export const version = "2.5.2";
4
4
  export const 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
  *
package/dist/esm/tools.js CHANGED
@@ -19,6 +19,15 @@ const CONNECTION_GONE_CODES = new Set([ImapFlowErrorCode.NoConnection, ImapFlowE
19
19
  // expand server sequences (e.g. ESEARCH ALL in commands/search.ts) can apply the same absolute
20
20
  // ceiling instead of inventing their own.
21
21
  export const EXPANDED_RANGE_LIMIT = 0x1000000;
22
+ // Upper bound for the untagged responses one command may accumulate in memory: the FETCH rows
23
+ // fetchAll() collects and the mailboxes list() collects. The response size limits bound one
24
+ // response at a time, not how many of them a server sends in reply to one command, so a hostile
25
+ // server answering a single-message FETCH with an endless stream of unsolicited FETCH rows (or a
26
+ // LIST with endless mailboxes) would otherwise grow the process without limit. Generous on
27
+ // purpose: a million rows is far beyond any legitimate listing, and a mailbox that large is read
28
+ // through the streaming fetch() generator, which keeps nothing. Reaching it therefore fails the
29
+ // command (code TooManyResponses) instead of silently truncating its result.
30
+ export const MAX_COLLECTED_RESPONSES = 1000000;
22
31
  // Digit bounds for untrusted numeric values in server responses. UIDs, UIDVALIDITY and
23
32
  // message counts are 32-bit unsigned (nz-number in the RFC 9051 grammar, so at most 10
24
33
  // digits); MODSEQ and number64 values are 63-bit unsigned (RFC 7162, RFC 9051), at most
@@ -547,13 +556,30 @@ export function getFolderTree(folders) {
547
556
  root: true,
548
557
  folders: []
549
558
  };
559
+ // Children of every node indexed by name. Looking a child up with folders.find() made the
560
+ // tree quadratic in the number of siblings: a listing of 50k mailboxes at one level (a size
561
+ // the server chooses) took seconds of event loop time. A name is unique among its siblings,
562
+ // as the loop below updates an existing entry rather than adding a second one.
563
+ let childIndex = new Map();
564
+ let childByName = (node, name) => {
565
+ let index = childIndex.get(node);
566
+ return index ? index.get(name) : undefined;
567
+ };
568
+ let indexChild = (node, child) => {
569
+ let index = childIndex.get(node);
570
+ if (!index) {
571
+ index = new Map();
572
+ childIndex.set(node, index);
573
+ }
574
+ index.set(child.name, child);
575
+ };
550
576
  let getTreeNode = (parents) => {
551
577
  let node = tree;
552
578
  if (!parents || !parents.length) {
553
579
  return node;
554
580
  }
555
581
  for (let parent of parents) {
556
- let cur = node.folders && node.folders.find(folder => folder.name === parent);
582
+ let cur = childByName(node, parent);
557
583
  if (cur) {
558
584
  node = cur;
559
585
  }
@@ -568,7 +594,7 @@ export function getFolderTree(folders) {
568
594
  for (let folder of byDepth) {
569
595
  let parent = getTreeNode(folder.parent);
570
596
  // see if entry already exists
571
- let existing = parent.folders && parent.folders.find(existing => existing.name === folder.name);
597
+ let existing = childByName(parent, folder.name);
572
598
  if (existing) {
573
599
  // update values
574
600
  existing.name = folder.name;
@@ -613,6 +639,7 @@ export function getFolderTree(folders) {
613
639
  parent.folders = [];
614
640
  }
615
641
  parent.folders.push(data);
642
+ indexChild(parent, data);
616
643
  }
617
644
  }
618
645
  return tree;
@@ -1507,67 +1534,103 @@ export function parseUintValue(value, maxDigits) {
1507
1534
  return Number.isSafeInteger(num) ? num : false;
1508
1535
  }
1509
1536
  /**
1510
- * Expands an IMAP sequence range string (e.g. "1:3,5,7:9") into an array of numbers.
1537
+ * Parses an IMAP sequence set string (e.g. "1:3,5,9:7") into its entries, each as the pair of
1538
+ * endpoints in the order written: a reversed "9:7" stays reversed, and iterateRange() walks it
1539
+ * downwards. Entries with an endpoint that is not a valid nz-number are skipped - the input may
1540
+ * come from an untrusted server, and 'Infinity' or similar garbage would otherwise loop without
1541
+ * bound.
1511
1542
  *
1512
- * Entries with endpoints that are not valid nz-numbers are skipped - the input
1513
- * may come from an untrusted server, and 'Infinity' or similar garbage would
1514
- * otherwise loop without bound. The whole set is expanded to at most
1515
- * EXPANDED_RANGE_LIMIT entries in total: legitimate responses never reach the limit
1516
- * (the mailbox would need that many messages), while hostile input is cut off
1517
- * instead of exhausting memory. The total is capped, not just each range -
1518
- * otherwise "1:16777216,1:16777216,..." would multiply the per-range bound by an
1519
- * unbounded number of ranges.
1520
- *
1521
- * @param range - IMAP sequence range string
1522
- * @returns Array of expanded sequence numbers
1543
+ * @param range - IMAP sequence set string, or whatever the response parser produced for it
1544
+ * @returns The validated entries
1523
1545
  */
1524
- export function expandRange(range) {
1525
- let result = [];
1546
+ function parseRangeEntries(range) {
1547
+ let entries = [];
1526
1548
  // Callers pass whatever the response parser produced for the sequence set, and a
1527
1549
  // malformed response can leave that as `false` (e.g. a VANISHED response carrying
1528
1550
  // only the (EARLIER) tag). Nothing to expand then, and throwing here would abort
1529
1551
  // the handler for the rest of the response.
1530
1552
  if (typeof range !== 'string') {
1531
- return result;
1553
+ return entries;
1532
1554
  }
1533
1555
  for (let entry of range.split(',')) {
1534
- if (result.length >= EXPANDED_RANGE_LIMIT) {
1535
- break;
1536
- }
1537
1556
  entry = entry.trim();
1538
1557
  let colon = entry.indexOf(':');
1539
1558
  if (colon < 0) {
1540
1559
  let value = Number(entry);
1541
1560
  if (isValidSequenceValue(value)) {
1542
- result.push(value);
1561
+ entries.push([value, value]);
1543
1562
  }
1544
1563
  continue;
1545
1564
  }
1546
1565
  let first = Number(entry.substring(0, colon));
1547
1566
  let second = Number(entry.slice(colon + 1));
1548
- if (!isValidSequenceValue(first) || !isValidSequenceValue(second)) {
1549
- continue;
1567
+ if (isValidSequenceValue(first) && isValidSequenceValue(second)) {
1568
+ entries.push([first, second]);
1550
1569
  }
1551
- if (first === second) {
1552
- result.push(first);
1553
- continue;
1554
- }
1555
- // Remaining total budget doubles as the per-range bound
1556
- let remaining = EXPANDED_RANGE_LIMIT - result.length;
1557
- if (first < second) {
1558
- let last = Math.min(second, first + remaining - 1);
1559
- for (let i = first; i <= last; i++) {
1560
- result.push(i);
1570
+ }
1571
+ return entries;
1572
+ }
1573
+ /**
1574
+ * Walks an IMAP sequence range string (e.g. "1:3,5,7:9") one number at a time, without
1575
+ * materializing it. A single "1:16777216" is a few bytes on the wire but sixteen million
1576
+ * entries expanded, so a consumer that only needs to visit each number (the VANISHED handler)
1577
+ * iterates instead of calling expandRange().
1578
+ *
1579
+ * The same bound as expandRange() applies: at most EXPANDED_RANGE_LIMIT numbers in total are
1580
+ * produced, over all entries of the set.
1581
+ *
1582
+ * @param range - IMAP sequence range string
1583
+ * @returns The sequence numbers, in the order the set lists them
1584
+ */
1585
+ export function* iterateRange(range) {
1586
+ let count = 0;
1587
+ for (let [first, second] of parseRangeEntries(range)) {
1588
+ let step = first <= second ? 1 : -1;
1589
+ for (let i = first; count < EXPANDED_RANGE_LIMIT; i += step) {
1590
+ yield i;
1591
+ count++;
1592
+ if (i === second) {
1593
+ break;
1561
1594
  }
1562
1595
  }
1563
- else {
1564
- let last = Math.max(second, first - remaining + 1);
1565
- for (let i = first; i >= last; i--) {
1566
- result.push(i);
1567
- }
1596
+ if (count >= EXPANDED_RANGE_LIMIT) {
1597
+ break;
1568
1598
  }
1569
1599
  }
1570
- return result;
1600
+ }
1601
+ /**
1602
+ * Counts the numbers iterateRange() produces for a sequence range string, without walking it.
1603
+ *
1604
+ * @param range - IMAP sequence range string
1605
+ * @returns The number of entries, capped at EXPANDED_RANGE_LIMIT like the expansion itself
1606
+ */
1607
+ export function countRange(range) {
1608
+ let count = 0;
1609
+ for (let [first, second] of parseRangeEntries(range)) {
1610
+ count += Math.abs(second - first) + 1;
1611
+ if (count >= EXPANDED_RANGE_LIMIT) {
1612
+ return EXPANDED_RANGE_LIMIT;
1613
+ }
1614
+ }
1615
+ return count;
1616
+ }
1617
+ /**
1618
+ * Expands an IMAP sequence range string (e.g. "1:3,5,7:9") into an array of numbers.
1619
+ *
1620
+ * Entries with endpoints that are not valid nz-numbers are skipped - the input
1621
+ * may come from an untrusted server, and 'Infinity' or similar garbage would
1622
+ * otherwise loop without bound. The whole set is expanded to at most
1623
+ * EXPANDED_RANGE_LIMIT entries in total: legitimate responses never reach the limit
1624
+ * (the mailbox would need that many messages), while hostile input is cut off
1625
+ * instead of exhausting memory. The total is capped, not just each range -
1626
+ * otherwise "1:16777216,1:16777216,..." would multiply the per-range bound by an
1627
+ * unbounded number of ranges.
1628
+ *
1629
+ * @param range - IMAP sequence range string
1630
+ * @returns Array of expanded sequence numbers
1631
+ */
1632
+ export function expandRange(range) {
1633
+ return Array.from(iterateRange(range));
1571
1634
  }
1572
1635
  /**
1573
1636
  * Returns a stream decoder for the given charset. Uses a special Japanese
@@ -1609,20 +1672,21 @@ export function packMessageRange(list) {
1609
1672
  // Deduplicate before sorting so that repeated values do not produce
1610
1673
  // overlapping/non-canonical tokens (e.g. [1,1,2,3] -> "1:3", not "1,1:3").
1611
1674
  items = Array.from(new Set(items)).sort((a, b) => a - b);
1612
- let last = items[items.length - 1];
1613
- let result = [[last]];
1614
- for (let i = items.length - 2; i >= 0; i--) {
1615
- if (items[i] === items[i + 1] - 1) {
1616
- result[0].unshift(items[i]);
1675
+ // One forward pass: a run of consecutive values is written out as soon as it ends. The runs
1676
+ // used to be built with unshift(), which copies the array on every call, so packing a large
1677
+ // SEARCH result (a server-chosen size) took seconds for 200k UIDs and about a minute for 1M,
1678
+ // all of it on the event loop.
1679
+ let parts = [];
1680
+ let start = items[0];
1681
+ let prev = start;
1682
+ for (let i = 1; i <= items.length; i++) {
1683
+ let value = items[i];
1684
+ if (i < items.length && value === prev + 1) {
1685
+ prev = value;
1617
1686
  continue;
1618
1687
  }
1619
- result.unshift([items[i]]);
1688
+ parts.push(start === prev ? String(start) : start + ':' + prev);
1689
+ start = prev = value;
1620
1690
  }
1621
- let parts = result.map(item => {
1622
- if (item.length === 1) {
1623
- return item[0];
1624
- }
1625
- return item.shift() + ':' + item.pop();
1626
- });
1627
1691
  return parts.join(',');
1628
1692
  }
@@ -170,7 +170,8 @@ export interface ImapFlowOptions {
170
170
  * when the server offers STARTTLS and otherwise continues in cleartext with a warning logged:
171
171
  * the capability list that decides this was itself received in cleartext, so an attacker on
172
172
  * the path can strip it. Set `doSTARTTLS: true` whenever the credentials must never cross the
173
- * wire unencrypted.
173
+ * wire unencrypted. STARTTLS is only possible before authentication, so a server greeting
174
+ * with PREAUTH is a connection failure when the upgrade is required.
174
175
  */
175
176
  doSTARTTLS?: boolean | undefined;
176
177
  /** Custom instance ID string for logs */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "IMAP Client for Node",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/imap-flow.js",
@@ -95,12 +95,12 @@
95
95
  "wrangler": "4.151.0"
96
96
  },
97
97
  "dependencies": {
98
- "@zone-eu/mailsplit": "5.4.21",
98
+ "@zone-eu/mailsplit": "5.4.23",
99
99
  "encoding-japanese": "2.4.0",
100
100
  "iconv-lite": "0.7.3",
101
101
  "libbase64": "1.3.3",
102
- "libmime": "5.4.8",
103
- "libqp": "2.1.2",
102
+ "libmime": "5.4.9",
103
+ "libqp": "2.1.3",
104
104
  "pino": "10.3.1",
105
105
  "socks": "2.8.10"
106
106
  },