imapflow 2.5.0 → 2.5.1

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 +7 -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 +1 -1
package/dist/cjs/tools.js CHANGED
@@ -4,7 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  return (mod && mod.__esModule) ? mod : { "default": mod };
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.noop = exports.MAX_UINT32_DIGITS = exports.EXPANDED_RANGE_LIMIT = exports.AuthenticationFailure = void 0;
7
+ exports.noop = exports.MAX_UINT32_DIGITS = exports.MAX_COLLECTED_RESPONSES = exports.EXPANDED_RANGE_LIMIT = exports.AuthenticationFailure = void 0;
8
8
  exports.isIpLiteral = isIpLiteral;
9
9
  exports.buildConnectionError = buildConnectionError;
10
10
  exports.restampConnectionError = restampConnectionError;
@@ -53,6 +53,8 @@ exports.getStringList = getStringList;
53
53
  exports.getStringValue = getStringValue;
54
54
  exports.parseBigIntValue = parseBigIntValue;
55
55
  exports.parseUintValue = parseUintValue;
56
+ exports.iterateRange = iterateRange;
57
+ exports.countRange = countRange;
56
58
  exports.expandRange = expandRange;
57
59
  exports.getDecoder = getDecoder;
58
60
  exports.packMessageRange = packMessageRange;
@@ -77,6 +79,15 @@ const CONNECTION_GONE_CODES = new Set([errors_js_1.ImapFlowErrorCode.NoConnectio
77
79
  // expand server sequences (e.g. ESEARCH ALL in commands/search.ts) can apply the same absolute
78
80
  // ceiling instead of inventing their own.
79
81
  exports.EXPANDED_RANGE_LIMIT = 0x1000000;
82
+ // Upper bound for the untagged responses one command may accumulate in memory: the FETCH rows
83
+ // fetchAll() collects and the mailboxes list() collects. The response size limits bound one
84
+ // response at a time, not how many of them a server sends in reply to one command, so a hostile
85
+ // server answering a single-message FETCH with an endless stream of unsolicited FETCH rows (or a
86
+ // LIST with endless mailboxes) would otherwise grow the process without limit. Generous on
87
+ // purpose: a million rows is far beyond any legitimate listing, and a mailbox that large is read
88
+ // through the streaming fetch() generator, which keeps nothing. Reaching it therefore fails the
89
+ // command (code TooManyResponses) instead of silently truncating its result.
90
+ exports.MAX_COLLECTED_RESPONSES = 1000000;
80
91
  // Digit bounds for untrusted numeric values in server responses. UIDs, UIDVALIDITY and
81
92
  // message counts are 32-bit unsigned (nz-number in the RFC 9051 grammar, so at most 10
82
93
  // digits); MODSEQ and number64 values are 63-bit unsigned (RFC 7162, RFC 9051), at most
@@ -606,13 +617,30 @@ function getFolderTree(folders) {
606
617
  root: true,
607
618
  folders: []
608
619
  };
620
+ // Children of every node indexed by name. Looking a child up with folders.find() made the
621
+ // tree quadratic in the number of siblings: a listing of 50k mailboxes at one level (a size
622
+ // the server chooses) took seconds of event loop time. A name is unique among its siblings,
623
+ // as the loop below updates an existing entry rather than adding a second one.
624
+ let childIndex = new Map();
625
+ let childByName = (node, name) => {
626
+ let index = childIndex.get(node);
627
+ return index ? index.get(name) : undefined;
628
+ };
629
+ let indexChild = (node, child) => {
630
+ let index = childIndex.get(node);
631
+ if (!index) {
632
+ index = new Map();
633
+ childIndex.set(node, index);
634
+ }
635
+ index.set(child.name, child);
636
+ };
609
637
  let getTreeNode = (parents) => {
610
638
  let node = tree;
611
639
  if (!parents || !parents.length) {
612
640
  return node;
613
641
  }
614
642
  for (let parent of parents) {
615
- let cur = node.folders && node.folders.find(folder => folder.name === parent);
643
+ let cur = childByName(node, parent);
616
644
  if (cur) {
617
645
  node = cur;
618
646
  }
@@ -627,7 +655,7 @@ function getFolderTree(folders) {
627
655
  for (let folder of byDepth) {
628
656
  let parent = getTreeNode(folder.parent);
629
657
  // see if entry already exists
630
- let existing = parent.folders && parent.folders.find(existing => existing.name === folder.name);
658
+ let existing = childByName(parent, folder.name);
631
659
  if (existing) {
632
660
  // update values
633
661
  existing.name = folder.name;
@@ -672,6 +700,7 @@ function getFolderTree(folders) {
672
700
  parent.folders = [];
673
701
  }
674
702
  parent.folders.push(data);
703
+ indexChild(parent, data);
675
704
  }
676
705
  }
677
706
  return tree;
@@ -1566,67 +1595,103 @@ function parseUintValue(value, maxDigits) {
1566
1595
  return Number.isSafeInteger(num) ? num : false;
1567
1596
  }
1568
1597
  /**
1569
- * Expands an IMAP sequence range string (e.g. "1:3,5,7:9") into an array of numbers.
1598
+ * Parses an IMAP sequence set string (e.g. "1:3,5,9:7") into its entries, each as the pair of
1599
+ * endpoints in the order written: a reversed "9:7" stays reversed, and iterateRange() walks it
1600
+ * downwards. Entries with an endpoint that is not a valid nz-number are skipped - the input may
1601
+ * come from an untrusted server, and 'Infinity' or similar garbage would otherwise loop without
1602
+ * bound.
1570
1603
  *
1571
- * Entries with endpoints that are not valid nz-numbers are skipped - the input
1572
- * may come from an untrusted server, and 'Infinity' or similar garbage would
1573
- * otherwise loop without bound. The whole set is expanded to at most
1574
- * EXPANDED_RANGE_LIMIT entries in total: legitimate responses never reach the limit
1575
- * (the mailbox would need that many messages), while hostile input is cut off
1576
- * instead of exhausting memory. The total is capped, not just each range -
1577
- * otherwise "1:16777216,1:16777216,..." would multiply the per-range bound by an
1578
- * unbounded number of ranges.
1579
- *
1580
- * @param range - IMAP sequence range string
1581
- * @returns Array of expanded sequence numbers
1604
+ * @param range - IMAP sequence set string, or whatever the response parser produced for it
1605
+ * @returns The validated entries
1582
1606
  */
1583
- function expandRange(range) {
1584
- let result = [];
1607
+ function parseRangeEntries(range) {
1608
+ let entries = [];
1585
1609
  // Callers pass whatever the response parser produced for the sequence set, and a
1586
1610
  // malformed response can leave that as `false` (e.g. a VANISHED response carrying
1587
1611
  // only the (EARLIER) tag). Nothing to expand then, and throwing here would abort
1588
1612
  // the handler for the rest of the response.
1589
1613
  if (typeof range !== 'string') {
1590
- return result;
1614
+ return entries;
1591
1615
  }
1592
1616
  for (let entry of range.split(',')) {
1593
- if (result.length >= exports.EXPANDED_RANGE_LIMIT) {
1594
- break;
1595
- }
1596
1617
  entry = entry.trim();
1597
1618
  let colon = entry.indexOf(':');
1598
1619
  if (colon < 0) {
1599
1620
  let value = Number(entry);
1600
1621
  if (isValidSequenceValue(value)) {
1601
- result.push(value);
1622
+ entries.push([value, value]);
1602
1623
  }
1603
1624
  continue;
1604
1625
  }
1605
1626
  let first = Number(entry.substring(0, colon));
1606
1627
  let second = Number(entry.slice(colon + 1));
1607
- if (!isValidSequenceValue(first) || !isValidSequenceValue(second)) {
1608
- continue;
1628
+ if (isValidSequenceValue(first) && isValidSequenceValue(second)) {
1629
+ entries.push([first, second]);
1609
1630
  }
1610
- if (first === second) {
1611
- result.push(first);
1612
- continue;
1613
- }
1614
- // Remaining total budget doubles as the per-range bound
1615
- let remaining = exports.EXPANDED_RANGE_LIMIT - result.length;
1616
- if (first < second) {
1617
- let last = Math.min(second, first + remaining - 1);
1618
- for (let i = first; i <= last; i++) {
1619
- result.push(i);
1631
+ }
1632
+ return entries;
1633
+ }
1634
+ /**
1635
+ * Walks an IMAP sequence range string (e.g. "1:3,5,7:9") one number at a time, without
1636
+ * materializing it. A single "1:16777216" is a few bytes on the wire but sixteen million
1637
+ * entries expanded, so a consumer that only needs to visit each number (the VANISHED handler)
1638
+ * iterates instead of calling expandRange().
1639
+ *
1640
+ * The same bound as expandRange() applies: at most EXPANDED_RANGE_LIMIT numbers in total are
1641
+ * produced, over all entries of the set.
1642
+ *
1643
+ * @param range - IMAP sequence range string
1644
+ * @returns The sequence numbers, in the order the set lists them
1645
+ */
1646
+ function* iterateRange(range) {
1647
+ let count = 0;
1648
+ for (let [first, second] of parseRangeEntries(range)) {
1649
+ let step = first <= second ? 1 : -1;
1650
+ for (let i = first; count < exports.EXPANDED_RANGE_LIMIT; i += step) {
1651
+ yield i;
1652
+ count++;
1653
+ if (i === second) {
1654
+ break;
1620
1655
  }
1621
1656
  }
1622
- else {
1623
- let last = Math.max(second, first - remaining + 1);
1624
- for (let i = first; i >= last; i--) {
1625
- result.push(i);
1626
- }
1657
+ if (count >= exports.EXPANDED_RANGE_LIMIT) {
1658
+ break;
1627
1659
  }
1628
1660
  }
1629
- return result;
1661
+ }
1662
+ /**
1663
+ * Counts the numbers iterateRange() produces for a sequence range string, without walking it.
1664
+ *
1665
+ * @param range - IMAP sequence range string
1666
+ * @returns The number of entries, capped at EXPANDED_RANGE_LIMIT like the expansion itself
1667
+ */
1668
+ function countRange(range) {
1669
+ let count = 0;
1670
+ for (let [first, second] of parseRangeEntries(range)) {
1671
+ count += Math.abs(second - first) + 1;
1672
+ if (count >= exports.EXPANDED_RANGE_LIMIT) {
1673
+ return exports.EXPANDED_RANGE_LIMIT;
1674
+ }
1675
+ }
1676
+ return count;
1677
+ }
1678
+ /**
1679
+ * Expands an IMAP sequence range string (e.g. "1:3,5,7:9") into an array of numbers.
1680
+ *
1681
+ * Entries with endpoints that are not valid nz-numbers are skipped - the input
1682
+ * may come from an untrusted server, and 'Infinity' or similar garbage would
1683
+ * otherwise loop without bound. The whole set is expanded to at most
1684
+ * EXPANDED_RANGE_LIMIT entries in total: legitimate responses never reach the limit
1685
+ * (the mailbox would need that many messages), while hostile input is cut off
1686
+ * instead of exhausting memory. The total is capped, not just each range -
1687
+ * otherwise "1:16777216,1:16777216,..." would multiply the per-range bound by an
1688
+ * unbounded number of ranges.
1689
+ *
1690
+ * @param range - IMAP sequence range string
1691
+ * @returns Array of expanded sequence numbers
1692
+ */
1693
+ function expandRange(range) {
1694
+ return Array.from(iterateRange(range));
1630
1695
  }
1631
1696
  /**
1632
1697
  * Returns a stream decoder for the given charset. Uses a special Japanese
@@ -1668,20 +1733,21 @@ function packMessageRange(list) {
1668
1733
  // Deduplicate before sorting so that repeated values do not produce
1669
1734
  // overlapping/non-canonical tokens (e.g. [1,1,2,3] -> "1:3", not "1,1:3").
1670
1735
  items = Array.from(new Set(items)).sort((a, b) => a - b);
1671
- let last = items[items.length - 1];
1672
- let result = [[last]];
1673
- for (let i = items.length - 2; i >= 0; i--) {
1674
- if (items[i] === items[i + 1] - 1) {
1675
- result[0].unshift(items[i]);
1736
+ // One forward pass: a run of consecutive values is written out as soon as it ends. The runs
1737
+ // used to be built with unshift(), which copies the array on every call, so packing a large
1738
+ // SEARCH result (a server-chosen size) took seconds for 200k UIDs and about a minute for 1M,
1739
+ // all of it on the event loop.
1740
+ let parts = [];
1741
+ let start = items[0];
1742
+ let prev = start;
1743
+ for (let i = 1; i <= items.length; i++) {
1744
+ let value = items[i];
1745
+ if (i < items.length && value === prev + 1) {
1746
+ prev = value;
1676
1747
  continue;
1677
1748
  }
1678
- result.unshift([items[i]]);
1749
+ parts.push(start === prev ? String(start) : start + ':' + prev);
1750
+ start = prev = value;
1679
1751
  }
1680
- let parts = result.map(item => {
1681
- if (item.length === 1) {
1682
- return item[0];
1683
- }
1684
- return item.shift() + ':' + item.pop();
1685
- });
1686
1752
  return parts.join(',');
1687
1753
  }
@@ -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 */
@@ -1,4 +1,33 @@
1
1
  import { getStatusCode, getErrorText, isServerRefusal } from '../tools.js';
2
+ import { createImapError } from '../errors.js';
3
+ // The OAuth SASL payloads are framed with \x01 (RFC 7628 section 3.1, and the XOAUTH2 format
4
+ // copies it), so a control character in a value ends the field early and the rest of the value
5
+ // becomes fields of its own, auth= included. RFC 7628 allows only VCHAR, SP, HTAB, CR and LF in
6
+ // a value, and none of the controls has a place in a username or a bearer token.
7
+ const RE_CONTROL_CHARS = /[\x00-\x1f\x7f]/;
8
+ /**
9
+ * Refuses a username or token that could not be framed as one SASL field.
10
+ *
11
+ * @param value - The value about to be placed in the payload
12
+ * @param what - What the value is, for the error message
13
+ * @throws If the value contains a control character
14
+ */
15
+ function assertSaslValue(value, what) {
16
+ if (RE_CONTROL_CHARS.test(value)) {
17
+ throw createImapError(`The ${what} contains control characters`, 'InvalidStringValue');
18
+ }
19
+ }
20
+ /**
21
+ * Escapes a value for the authzid of a GS2 header (RFC 5801 section 4): "," and "=" delimit
22
+ * the header, so they are written as =2C and =3D. "=" goes first, so the "=" the escaping
23
+ * itself introduces is not escaped again.
24
+ *
25
+ * @param value - The saslname
26
+ * @returns The escaped saslname
27
+ */
28
+ function escapeSaslName(value) {
29
+ return value.replace(/[=]/g, '=3D').replace(/,/g, '=2C');
30
+ }
2
31
  /**
3
32
  * Handles authentication errors by enriching the error object with server response details.
4
33
  *
@@ -34,6 +63,8 @@ async function authOauth(connection, username, accessToken) {
34
63
  let oauthbearer;
35
64
  let command;
36
65
  let breaker;
66
+ assertSaslValue(username, 'username');
67
+ assertSaslValue(accessToken, 'access token');
37
68
  if (connection.capabilities.has('AUTH=OAUTHBEARER')) {
38
69
  // OAUTHBEARER payload per RFC 7628: fields separated by \x01 (SASL GS2 framing).
39
70
  // Format: "n,a=<user>," \x01 "host=..." \x01 "port=..." \x01 "auth=Bearer <token>" \x01 \x01
@@ -44,7 +75,7 @@ async function authOauth(connection, username, accessToken) {
44
75
  // A server validating either field rejects with status `invalid_request` - a permanent
45
76
  // failure that refreshing the access token can never clear.
46
77
  oauthbearer = [
47
- `n,a=${username},`,
78
+ `n,a=${escapeSaslName(username)},`,
48
79
  `host=${connection.servername || connection.host}`,
49
80
  `port=${connection.port}`,
50
81
  `auth=Bearer ${accessToken}`,
@@ -1,4 +1,5 @@
1
- import { formatMessageResponse, isRev2Active, getSelectedMailbox } from '../tools.js';
1
+ import { formatMessageResponse, isRev2Active, getSelectedMailbox, MAX_COLLECTED_RESPONSES } from '../tools.js';
2
+ import { createImapError } from '../errors.js';
2
3
  /**
3
4
  * Fetches emails from the server.
4
5
  *
@@ -35,9 +36,10 @@ export default async function fetch(connection, range, query, options) {
35
36
  count: 0,
36
37
  list: []
37
38
  };
38
- // The first error the onUntaggedFetch consumer reported through next(err). Errors thrown
39
- // by untagged handlers are only logged by the connection, so it is kept here and fails
40
- // the command once the FETCH completes; later messages are no longer handed to the consumer.
39
+ // The first error the onUntaggedFetch consumer reported through next(err), or the row
40
+ // limit being reached. Errors thrown by untagged handlers are only logged by the
41
+ // connection, so it is kept here and fails the command once the FETCH completes; later
42
+ // messages are no longer handed to the consumer or collected.
41
43
  let consumerError = null;
42
44
  let response;
43
45
  try {
@@ -232,6 +234,11 @@ export default async function fetch(connection, range, query, options) {
232
234
  consumerError = err;
233
235
  }
234
236
  }
237
+ else if (messages.list.length >= MAX_COLLECTED_RESPONSES) {
238
+ // The rows are the server's to send and the memory holding them is
239
+ // this process's, see MAX_COLLECTED_RESPONSES
240
+ consumerError = createImapError(`FETCH returned more than ${MAX_COLLECTED_RESPONSES} messages`, 'TooManyResponses');
241
+ }
235
242
  else {
236
243
  messages.list.push(formatted);
237
244
  }
@@ -37,6 +37,12 @@ async function runIdle(connection) {
37
37
  // connection, so a newer IDLE session that already installed its own is left alone.
38
38
  let ownPreCheck = null;
39
39
  let releaseIdling = claimIdling(connection);
40
+ // The deadline for the server's "+" continuation, see the onSend handler below
41
+ let continuationTimer = null;
42
+ let clearContinuationTimer = () => {
43
+ clearTimer(continuationTimer);
44
+ continuationTimer = null;
45
+ };
40
46
  try {
41
47
  // State flags for the IDLE lifecycle:
42
48
  // - doneRequested: someone wants to break IDLE (e.g., to run another command)
@@ -102,6 +108,7 @@ async function runIdle(connection) {
102
108
  // After this, the server will push untagged responses for mailbox changes.
103
109
  // We can now safely send DONE if a break was already requested.
104
110
  onPlusTag: async () => {
111
+ clearContinuationTimer();
105
112
  connection.log.debug({
106
113
  msg: `Initiated IDLE, waiting for server input`,
107
114
  lockId: connection.currentLock ? connection.currentLock.lockId : undefined,
@@ -118,7 +125,29 @@ async function runIdle(connection) {
118
125
  }
119
126
  }
120
127
  },
121
- onSend: () => { }
128
+ // The server has socketTimeout from the moment IDLE is on the wire to acknowledge it
129
+ // with the "+". The socket watchdog only covers a server that goes quiet: one that
130
+ // keeps sending untagged lines without ever answering "+" kept the IDLE in flight for
131
+ // good, and every later command queued behind it. DONE can not be sent before the
132
+ // "+" (RFC 2177 section 3), so there is no way to break such an IDLE and fall back
133
+ // to polling; the connection is failed the way the watchdog fails a dead peer.
134
+ onSend: () => {
135
+ continuationTimer = unrefTimer(setTimeout(() => {
136
+ continuationTimer = null;
137
+ if (canEnd || connection.isClosed) {
138
+ return;
139
+ }
140
+ connection.log.error({
141
+ msg: 'Server did not acknowledge IDLE, closing the connection',
142
+ timeout: connection.socketTimeout,
143
+ cid: connection.id
144
+ });
145
+ connection.emitError(createImapError('IDLE was not acknowledged within the socket timeout', 'ETIMEOUT', {
146
+ command: 'IDLE',
147
+ details: { socketTimeout: connection.socketTimeout }
148
+ }));
149
+ }, connection.socketTimeout));
150
+ }
122
151
  });
123
152
  response.next();
124
153
  return;
@@ -150,6 +179,7 @@ async function runIdle(connection) {
150
179
  // (including a server-terminated IDLE, where preCheck never ran), rejected command,
151
180
  // parser failure and connection close. `idling` drives socket-timeout handling, so it
152
181
  // must always describe reality rather than being left over from a previous state.
182
+ clearContinuationTimer();
153
183
  releaseIdling();
154
184
  if (connection.preCheck === ownPreCheck) {
155
185
  connection.preCheck = false;
@@ -1,4 +1,5 @@
1
- import { decodePath, encodePath, normalizePath, enhanceCommandError, hasCapability, isRev2Active, buildStatusQueryAttributes, getStringList, getStringValue } from '../tools.js';
1
+ import { decodePath, encodePath, normalizePath, enhanceCommandError, hasCapability, isRev2Active, buildStatusQueryAttributes, getStringList, getStringValue, MAX_COLLECTED_RESPONSES } from '../tools.js';
2
+ import { createImapError } from '../errors.js';
2
3
  import { parseStatusList } from './status-fields.js';
3
4
  import { specialUse } from '../special-use.js';
4
5
  /**
@@ -137,6 +138,8 @@ export default async function list(connection, reference, mailbox, options) {
137
138
  // namespace prefix was used (INBOX may live outside the namespace).
138
139
  let runList = async (reference, mailbox, returnArgs) => {
139
140
  const cmdArgs = [encodePath(connection, reference), encodePath(connection, mailbox)];
141
+ // Set once the entry limit is reached, fails the command when it completes
142
+ let overflow = null;
140
143
  if (returnArgs.length) {
141
144
  cmdArgs.push({ type: 'ATOM', value: 'RETURN' }, returnArgs);
142
145
  }
@@ -196,6 +199,14 @@ export default async function list(connection, reference, mailbox, options) {
196
199
  if (specialUseFlag && (!isNameSource(flagSource) || !entry.flags.has('\\NonExistent'))) {
197
200
  addSpecialUseMatch(entry, specialUseFlag, flagSource);
198
201
  }
202
+ if (entries.length >= MAX_COLLECTED_RESPONSES) {
203
+ // The entries are the server's to send and the memory holding them
204
+ // is this process's, see MAX_COLLECTED_RESPONSES. A throw from an
205
+ // untagged handler is only logged, so the error is raised below,
206
+ // once the command completes
207
+ overflow = overflow || createImapError(`LIST returned more than ${MAX_COLLECTED_RESPONSES} mailboxes`, 'TooManyResponses');
208
+ return;
209
+ }
199
210
  entries.push(entry);
200
211
  },
201
212
  // Inline STATUS response from LIST-STATUS extension (RFC 5819).
@@ -215,6 +226,9 @@ export default async function list(connection, reference, mailbox, options) {
215
226
  }
216
227
  });
217
228
  response.next();
229
+ if (overflow) {
230
+ throw overflow;
231
+ }
218
232
  };
219
233
  let normalizedReference = normalizePath(connection, reference || '');
220
234
  let normalizedMailbox = 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";
@@ -39,6 +39,8 @@ export const ImapFlowErrorCode = {
39
39
  LineTooLarge: 'LineTooLarge',
40
40
  LiteralTooLarge: 'LiteralTooLarge',
41
41
  ResponseTooLarge: 'ResponseTooLarge',
42
+ // one command received more untagged responses than are kept, see MAX_COLLECTED_RESPONSES
43
+ TooManyResponses: 'TooManyResponses',
42
44
  // invalid values in a command
43
45
  InvalidStringValue: 'InvalidStringValue',
44
46
  InvalidTokenValue: 'InvalidTokenValue',
@@ -81,6 +81,9 @@ export default async function parser(command, options) {
81
81
  if (parserInstance.remainder.trim().length) {
82
82
  await parserInstance.getSpace();
83
83
  response.attributes = await parserInstance.getAttributes();
84
+ if (parserInstance.truncatedAttributes && parserInstance.truncatedAttributes.length) {
85
+ response.truncatedAttributes = parserInstance.truncatedAttributes;
86
+ }
84
87
  }
85
88
  if (parserInstance.humanReadable) {
86
89
  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
  *
@@ -211,6 +211,8 @@ export class ParserInstance {
211
211
  });
212
212
  }
213
213
  const tokenParser = new TokenParser(this, this.pos, this.remainder, this.options);
214
- return await tokenParser.getAttributes();
214
+ let attributes = await tokenParser.getAttributes();
215
+ this.truncatedAttributes = tokenParser.truncatedAttributes;
216
+ return attributes;
215
217
  }
216
218
  }
@@ -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)