imapflow 2.4.3 → 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 (49) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +59 -1
  3. package/dist/cjs/commands/authenticate.js +32 -1
  4. package/dist/cjs/commands/fetch.js +10 -3
  5. package/dist/cjs/commands/idle.js +31 -1
  6. package/dist/cjs/commands/list.js +14 -0
  7. package/dist/cjs/errors.d.ts +2 -0
  8. package/dist/cjs/errors.js +4 -0
  9. package/dist/cjs/handler/imap-parser.js +3 -0
  10. package/dist/cjs/handler/parser-instance.d.ts +2 -0
  11. package/dist/cjs/handler/parser-instance.js +3 -1
  12. package/dist/cjs/handler/token-parser.d.ts +21 -2
  13. package/dist/cjs/handler/token-parser.js +76 -6
  14. package/dist/cjs/handler/types.d.ts +5 -0
  15. package/dist/cjs/imap-flow.d.ts +9 -9
  16. package/dist/cjs/imap-flow.js +90 -14
  17. package/dist/cjs/package-info.d.ts +1 -1
  18. package/dist/cjs/package-info.js +1 -1
  19. package/dist/cjs/sockets-transport.d.ts +44 -0
  20. package/dist/cjs/sockets-transport.js +89 -0
  21. package/dist/cjs/streams-transport.d.ts +84 -0
  22. package/dist/cjs/streams-transport.js +281 -0
  23. package/dist/cjs/tools.d.ts +30 -0
  24. package/dist/cjs/tools.js +130 -52
  25. package/dist/cjs/types.d.ts +78 -2
  26. package/dist/esm/commands/authenticate.js +32 -1
  27. package/dist/esm/commands/fetch.js +11 -4
  28. package/dist/esm/commands/idle.js +31 -1
  29. package/dist/esm/commands/list.js +15 -1
  30. package/dist/esm/errors.d.ts +2 -0
  31. package/dist/esm/errors.js +4 -0
  32. package/dist/esm/handler/imap-parser.js +3 -0
  33. package/dist/esm/handler/parser-instance.d.ts +2 -0
  34. package/dist/esm/handler/parser-instance.js +3 -1
  35. package/dist/esm/handler/token-parser.d.ts +21 -2
  36. package/dist/esm/handler/token-parser.js +76 -6
  37. package/dist/esm/handler/types.d.ts +5 -0
  38. package/dist/esm/imap-flow.d.ts +9 -9
  39. package/dist/esm/imap-flow.js +88 -14
  40. package/dist/esm/package-info.d.ts +1 -1
  41. package/dist/esm/package-info.js +1 -1
  42. package/dist/esm/sockets-transport.d.ts +44 -0
  43. package/dist/esm/sockets-transport.js +86 -0
  44. package/dist/esm/streams-transport.d.ts +84 -0
  45. package/dist/esm/streams-transport.js +278 -0
  46. package/dist/esm/tools.d.ts +30 -0
  47. package/dist/esm/tools.js +126 -51
  48. package/dist/esm/types.d.ts +78 -2
  49. package/package.json +16 -5
@@ -0,0 +1,281 @@
1
+ "use strict";
2
+ // The `transport` option over a socket API built on web streams: the socket opens with a promise
3
+ // that resolves to a ReadableStream and a WritableStream of bytes. `cloudflare:sockets` (celld,
4
+ // Cloudflare Workers) is one, see sockets-transport.ts; Chrome's Direct Sockets and in-memory
5
+ // connections fit the same shape. The streams are wrapped into a Duplex that behaves like the
6
+ // net.Socket ImapFlow drives.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.createStreamsTransport = createStreamsTransport;
9
+ const node_stream_1 = require("node:stream");
10
+ const tools_js_1 = require("./tools.js");
11
+ function closeQuietly(socket) {
12
+ try {
13
+ Promise.resolve(socket.close()).catch(tools_js_1.noop);
14
+ }
15
+ catch {
16
+ // already closed
17
+ }
18
+ }
19
+ class StreamsDuplex extends node_stream_1.Duplex {
20
+ constructor(api, socket, onConnect, plain) {
21
+ super({ allowHalfOpen: false });
22
+ // Taken once the socket is open: locking the streams of a socket returned by startTls()
23
+ // before its handshake completes stalls the handshake on celld
24
+ this._reader = null;
25
+ this._writer = null;
26
+ this._pumping = false;
27
+ this._readRequested = false;
28
+ // A runtime may only start TLS on a socket without a read in flight (with one, celld never
29
+ // completes the handshake), while the read loop always keeps one. So once the client
30
+ // announced a STARTTLS (holdAfterStarttls), reading stops after the tagged line that answers
31
+ // it, the only command in flight then, and resumes only when that line is not an OK.
32
+ this._awaitingStarttls = false;
33
+ this._lineTail = '';
34
+ // Set when the partial line got too long to be the one looked for and was dropped: the rest
35
+ // of it, up to the next line break, is skipped too
36
+ this._tailDropped = false;
37
+ this._readsHeld = false;
38
+ // Set once startTls() handed the connection to a TLS socket: the read that ends then is not
39
+ // the end of the connection, and closing this socket would close the TLS one
40
+ this._upgraded = false;
41
+ this._timeout = 0;
42
+ this._timer = null;
43
+ // Traffic only records the time, the one timer re-arms itself for the rest of the idle period
44
+ this._lastActivity = 0;
45
+ this._api = api;
46
+ this._socket = socket;
47
+ this._plain = plain || null;
48
+ socket.opened.then(info => {
49
+ if (this.destroyed) {
50
+ return;
51
+ }
52
+ try {
53
+ this._reader = info.readable.getReader();
54
+ this._writer = info.writable.getWriter();
55
+ }
56
+ catch (err) {
57
+ // streams already locked by someone else
58
+ this.destroy(err);
59
+ return;
60
+ }
61
+ this.remoteAddress = info.remoteAddress;
62
+ this.remotePort = info.remotePort;
63
+ this.localAddress = info.localAddress;
64
+ this.localPort = info.localPort;
65
+ const tls = info.tls;
66
+ if (tls) {
67
+ this.authorized = tls.authorized;
68
+ this.getCipher = () => ({ name: tls.name || '', standardName: tls.standardName || tls.name || '', version: tls.version || '' });
69
+ }
70
+ this._touch();
71
+ onConnect();
72
+ if (this._readRequested) {
73
+ void this._pump();
74
+ }
75
+ }, (err) => this.destroy(err));
76
+ socket.closed?.catch((err) => {
77
+ if (!this._upgraded) {
78
+ this.destroy(err);
79
+ }
80
+ });
81
+ }
82
+ _read() {
83
+ if (this._readsHeld) {
84
+ return;
85
+ }
86
+ if (!this._reader) {
87
+ this._readRequested = true;
88
+ }
89
+ else if (!this._pumping) {
90
+ void this._pump();
91
+ }
92
+ }
93
+ async _pump() {
94
+ const reader = this._reader;
95
+ this._pumping = true;
96
+ try {
97
+ for (;;) {
98
+ const { done, value } = await reader.read();
99
+ if (this._upgraded || this.destroyed) {
100
+ return;
101
+ }
102
+ if (done || !value) {
103
+ this.push(null);
104
+ return;
105
+ }
106
+ this._touch();
107
+ const chunk = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
108
+ const pushed = this.push(chunk);
109
+ if (this._awaitingStarttls && this._starttlsCompleted(chunk)) {
110
+ this._readsHeld = true;
111
+ return;
112
+ }
113
+ if (!pushed) {
114
+ return;
115
+ }
116
+ }
117
+ }
118
+ catch (err) {
119
+ if (!this._upgraded) {
120
+ this.destroy(err);
121
+ }
122
+ }
123
+ finally {
124
+ this._pumping = false;
125
+ }
126
+ }
127
+ // Whether the chunk holds the tagged response of the STARTTLS command and it is an OK. Lines
128
+ // end with LF, CR is optional, as for the parser
129
+ _starttlsCompleted(chunk) {
130
+ const skipFirst = this._tailDropped;
131
+ const lines = (this._lineTail + chunk.toString('latin1')).split('\n');
132
+ const tail = lines.pop();
133
+ if (skipFirst && !lines.length) {
134
+ // still inside the dropped line
135
+ return false;
136
+ }
137
+ this._tailDropped = tail.length > 1024;
138
+ this._lineTail = this._tailDropped ? '' : tail;
139
+ for (let i = skipFirst ? 1 : 0; i < lines.length; i++) {
140
+ const tagged = /^(?![*+] )\S+ (OK|NO|BAD)\b/i.exec(lines[i]);
141
+ if (tagged) {
142
+ this._awaitingStarttls = false;
143
+ return tagged[1].toUpperCase() === 'OK';
144
+ }
145
+ }
146
+ return false;
147
+ }
148
+ /** Stops reading after the tagged response of the STARTTLS command about to be sent, if it is an OK */
149
+ holdAfterStarttls() {
150
+ this._awaitingStarttls = true;
151
+ this._lineTail = '';
152
+ this._tailDropped = false;
153
+ }
154
+ _write(chunk, _encoding, callback) {
155
+ if (!this._writer) {
156
+ // ImapFlow writes only after onConnect, this is a misuse
157
+ callback(new Error('Socket is not open yet'));
158
+ return;
159
+ }
160
+ this._touch();
161
+ this._writer.write(chunk).then(() => callback(), callback);
162
+ }
163
+ _destroy(err, callback) {
164
+ this._clearTimer();
165
+ if (!this._upgraded) {
166
+ this._close();
167
+ }
168
+ if (this._plain) {
169
+ // the plain connection went with the TLS one, unless the API keeps it open
170
+ this._plain._close();
171
+ this._plain.destroy();
172
+ }
173
+ callback(err);
174
+ }
175
+ // Closes the connection. An API that refuses to close while its streams are locked (Direct
176
+ // Sockets) gets a second attempt with the locks released; releasing them right away would
177
+ // fail the read in flight, which ends the request on celld
178
+ _close() {
179
+ const retry = () => {
180
+ for (const stream of [this._reader, this._writer]) {
181
+ try {
182
+ stream?.releaseLock();
183
+ }
184
+ catch {
185
+ // an older streams implementation refuses with a read in flight
186
+ }
187
+ }
188
+ closeQuietly(this._socket);
189
+ };
190
+ try {
191
+ const closing = this._socket.close();
192
+ if (typeof closing?.then === 'function') {
193
+ closing.then(undefined, retry);
194
+ }
195
+ }
196
+ catch {
197
+ retry();
198
+ }
199
+ }
200
+ /** Like net.Socket#setTimeout(): emits 'timeout' after `ms` without traffic, 0 disables */
201
+ setTimeout(ms) {
202
+ this._timeout = ms;
203
+ this._clearTimer();
204
+ this._touch();
205
+ return this;
206
+ }
207
+ setKeepAlive() {
208
+ return this;
209
+ }
210
+ /** Hands the connection over to a TLS socket, this one only stays as its transport */
211
+ startTls(options, onSecure) {
212
+ this._upgraded = true;
213
+ this._clearTimer();
214
+ // workerd refuses to start TLS while the streams are locked, nothing is in flight on them
215
+ this._reader?.releaseLock();
216
+ this._writer?.releaseLock();
217
+ let tlsSocket;
218
+ try {
219
+ tlsSocket = this._api.upgrade(this._socket, options);
220
+ }
221
+ catch (err) {
222
+ // refused before any handshake, the connection is closed with this socket
223
+ this._upgraded = false;
224
+ throw err;
225
+ }
226
+ return new StreamsDuplex(this._api, tlsSocket, onSecure, this);
227
+ }
228
+ _touch() {
229
+ this._lastActivity = Date.now();
230
+ if (!this._timer) {
231
+ this._arm(this._timeout);
232
+ }
233
+ }
234
+ _arm(ms) {
235
+ if (this._timeout > 0 && !this.destroyed && !this._upgraded) {
236
+ this._timer = (0, tools_js_1.unrefTimer)(setTimeout(() => {
237
+ this._timer = null;
238
+ const idle = Date.now() - this._lastActivity;
239
+ if (idle >= this._timeout) {
240
+ this.emit('timeout');
241
+ }
242
+ else {
243
+ this._arm(this._timeout - idle);
244
+ }
245
+ }, ms));
246
+ }
247
+ }
248
+ _clearTimer() {
249
+ (0, tools_js_1.clearTimer)(this._timer);
250
+ this._timer = null;
251
+ }
252
+ }
253
+ /**
254
+ * Builds a transport for the `transport` option over a socket API built on web streams. A new
255
+ * API needs only `open()`, and `upgrade()` if it can start TLS on an open connection:
256
+ *
257
+ * ```js
258
+ * const transport = createStreamsTransport({
259
+ * open: ({ host, port, secure }) => {
260
+ * const socket = openSomeSocket(host, port, secure);
261
+ * return { opened: socket.ready.then(() => ({ readable: socket.readable, writable: socket.writable })), close: () => socket.close() };
262
+ * }
263
+ * });
264
+ * ```
265
+ *
266
+ * The transport stops reading once the server accepted a STARTTLS, so the API never has to start
267
+ * TLS with a read in flight, and releases the stream locks before `upgrade()` is called.
268
+ *
269
+ * @param api - Opens (and upgrades) connections
270
+ * @returns A transport for the `transport` option
271
+ */
272
+ function createStreamsTransport(api) {
273
+ const transport = {
274
+ connect: (options, onConnect) => new StreamsDuplex(api, api.open(options), onConnect)
275
+ };
276
+ if (api.upgrade) {
277
+ transport.prepareUpgrade = socket => socket.holdAfterStarttls();
278
+ transport.upgrade = (socket, options, onSecure) => socket.startTls(options, onSecure);
279
+ }
280
+ return transport;
281
+ }
@@ -5,8 +5,18 @@ 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;
11
+ /**
12
+ * Tells an IP literal from a hostname without `node:net`, which some runtimes do not implement.
13
+ * Only used to decide whether a host can be a TLS servername: a dotted quad, or anything with a
14
+ * colon (an IPv6 literal, a hostname never contains one).
15
+ *
16
+ * @param host - Hostname or IP address
17
+ * @returns true for an IP literal
18
+ */
19
+ export declare function isIpLiteral(host: string): boolean;
10
20
  /**
11
21
  * A stream decoder returned by getDecoder(). The Japanese decoder reports the `limited`
12
22
  * flag once it has buffered all it will accept; a streaming iconv decoder never sets it.
@@ -462,6 +472,26 @@ export declare function parseBigIntValue(value: unknown, maxDigits?: number): bi
462
472
  * @returns The parsed value, or false when it is not usable.
463
473
  */
464
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;
465
495
  /**
466
496
  * Expands an IMAP sequence range string (e.g. "1:3,5,7:9") into an array of numbers.
467
497
  *
package/dist/cjs/tools.js CHANGED
@@ -4,7 +4,8 @@ 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
+ exports.isIpLiteral = isIpLiteral;
8
9
  exports.buildConnectionError = buildConnectionError;
9
10
  exports.restampConnectionError = restampConnectionError;
10
11
  exports.guardedPromise = guardedPromise;
@@ -52,6 +53,8 @@ exports.getStringList = getStringList;
52
53
  exports.getStringValue = getStringValue;
53
54
  exports.parseBigIntValue = parseBigIntValue;
54
55
  exports.parseUintValue = parseUintValue;
56
+ exports.iterateRange = iterateRange;
57
+ exports.countRange = countRange;
55
58
  exports.expandRange = expandRange;
56
59
  exports.getDecoder = getDecoder;
57
60
  exports.packMessageRange = packMessageRange;
@@ -76,6 +79,15 @@ const CONNECTION_GONE_CODES = new Set([errors_js_1.ImapFlowErrorCode.NoConnectio
76
79
  // expand server sequences (e.g. ESEARCH ALL in commands/search.ts) can apply the same absolute
77
80
  // ceiling instead of inventing their own.
78
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;
79
91
  // Digit bounds for untrusted numeric values in server responses. UIDs, UIDVALIDITY and
80
92
  // message counts are 32-bit unsigned (nz-number in the RFC 9051 grammar, so at most 10
81
93
  // digits); MODSEQ and number64 values are 63-bit unsigned (RFC 7162, RFC 9051), at most
@@ -122,6 +134,17 @@ const IMAP4REV2_FOLDED_CAPABILITIES = new Set([
122
134
  // Deliberate no-op, used as the observer a guarded promise attaches to its own rejection.
123
135
  const noop = () => { };
124
136
  exports.noop = noop;
137
+ /**
138
+ * Tells an IP literal from a hostname without `node:net`, which some runtimes do not implement.
139
+ * Only used to decide whether a host can be a TLS servername: a dotted quad, or anything with a
140
+ * colon (an IPv6 literal, a hostname never contains one).
141
+ *
142
+ * @param host - Hostname or IP address
143
+ * @returns true for an IP literal
144
+ */
145
+ function isIpLiteral(host) {
146
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':');
147
+ }
125
148
  // The fields buildConnectionError() stamps to say *where* a connection error was rejected, as
126
149
  // opposed to what went wrong. restampConnectionError() clears them, because they belong to the
127
150
  // site that built the error rather than to the failure it describes.
@@ -594,13 +617,30 @@ function getFolderTree(folders) {
594
617
  root: true,
595
618
  folders: []
596
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
+ };
597
637
  let getTreeNode = (parents) => {
598
638
  let node = tree;
599
639
  if (!parents || !parents.length) {
600
640
  return node;
601
641
  }
602
642
  for (let parent of parents) {
603
- let cur = node.folders && node.folders.find(folder => folder.name === parent);
643
+ let cur = childByName(node, parent);
604
644
  if (cur) {
605
645
  node = cur;
606
646
  }
@@ -615,7 +655,7 @@ function getFolderTree(folders) {
615
655
  for (let folder of byDepth) {
616
656
  let parent = getTreeNode(folder.parent);
617
657
  // see if entry already exists
618
- let existing = parent.folders && parent.folders.find(existing => existing.name === folder.name);
658
+ let existing = childByName(parent, folder.name);
619
659
  if (existing) {
620
660
  // update values
621
661
  existing.name = folder.name;
@@ -660,6 +700,7 @@ function getFolderTree(folders) {
660
700
  parent.folders = [];
661
701
  }
662
702
  parent.folders.push(data);
703
+ indexChild(parent, data);
663
704
  }
664
705
  }
665
706
  return tree;
@@ -1554,67 +1595,103 @@ function parseUintValue(value, maxDigits) {
1554
1595
  return Number.isSafeInteger(num) ? num : false;
1555
1596
  }
1556
1597
  /**
1557
- * Expands an IMAP sequence range string (e.g. "1:3,5,7:9") into an array of numbers.
1558
- *
1559
- * Entries with endpoints that are not valid nz-numbers are skipped - the input
1560
- * may come from an untrusted server, and 'Infinity' or similar garbage would
1561
- * otherwise loop without bound. The whole set is expanded to at most
1562
- * EXPANDED_RANGE_LIMIT entries in total: legitimate responses never reach the limit
1563
- * (the mailbox would need that many messages), while hostile input is cut off
1564
- * instead of exhausting memory. The total is capped, not just each range -
1565
- * otherwise "1:16777216,1:16777216,..." would multiply the per-range bound by an
1566
- * unbounded number of ranges.
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.
1567
1603
  *
1568
- * @param range - IMAP sequence range string
1569
- * @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
1570
1606
  */
1571
- function expandRange(range) {
1572
- let result = [];
1607
+ function parseRangeEntries(range) {
1608
+ let entries = [];
1573
1609
  // Callers pass whatever the response parser produced for the sequence set, and a
1574
1610
  // malformed response can leave that as `false` (e.g. a VANISHED response carrying
1575
1611
  // only the (EARLIER) tag). Nothing to expand then, and throwing here would abort
1576
1612
  // the handler for the rest of the response.
1577
1613
  if (typeof range !== 'string') {
1578
- return result;
1614
+ return entries;
1579
1615
  }
1580
1616
  for (let entry of range.split(',')) {
1581
- if (result.length >= exports.EXPANDED_RANGE_LIMIT) {
1582
- break;
1583
- }
1584
1617
  entry = entry.trim();
1585
1618
  let colon = entry.indexOf(':');
1586
1619
  if (colon < 0) {
1587
1620
  let value = Number(entry);
1588
1621
  if (isValidSequenceValue(value)) {
1589
- result.push(value);
1622
+ entries.push([value, value]);
1590
1623
  }
1591
1624
  continue;
1592
1625
  }
1593
1626
  let first = Number(entry.substring(0, colon));
1594
1627
  let second = Number(entry.slice(colon + 1));
1595
- if (!isValidSequenceValue(first) || !isValidSequenceValue(second)) {
1596
- continue;
1597
- }
1598
- if (first === second) {
1599
- result.push(first);
1600
- continue;
1628
+ if (isValidSequenceValue(first) && isValidSequenceValue(second)) {
1629
+ entries.push([first, second]);
1601
1630
  }
1602
- // Remaining total budget doubles as the per-range bound
1603
- let remaining = exports.EXPANDED_RANGE_LIMIT - result.length;
1604
- if (first < second) {
1605
- let last = Math.min(second, first + remaining - 1);
1606
- for (let i = first; i <= last; i++) {
1607
- 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;
1608
1655
  }
1609
1656
  }
1610
- else {
1611
- let last = Math.max(second, first - remaining + 1);
1612
- for (let i = first; i >= last; i--) {
1613
- result.push(i);
1614
- }
1657
+ if (count >= exports.EXPANDED_RANGE_LIMIT) {
1658
+ break;
1615
1659
  }
1616
1660
  }
1617
- 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));
1618
1695
  }
1619
1696
  /**
1620
1697
  * Returns a stream decoder for the given charset. Uses a special Japanese
@@ -1656,20 +1733,21 @@ function packMessageRange(list) {
1656
1733
  // Deduplicate before sorting so that repeated values do not produce
1657
1734
  // overlapping/non-canonical tokens (e.g. [1,1,2,3] -> "1:3", not "1,1:3").
1658
1735
  items = Array.from(new Set(items)).sort((a, b) => a - b);
1659
- let last = items[items.length - 1];
1660
- let result = [[last]];
1661
- for (let i = items.length - 2; i >= 0; i--) {
1662
- if (items[i] === items[i + 1] - 1) {
1663
- 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;
1664
1747
  continue;
1665
1748
  }
1666
- result.unshift([items[i]]);
1749
+ parts.push(start === prev ? String(start) : start + ':' + prev);
1750
+ start = prev = value;
1667
1751
  }
1668
- let parts = result.map(item => {
1669
- if (item.length === 1) {
1670
- return item[0];
1671
- }
1672
- return item.shift() + ':' + item.pop();
1673
- });
1674
1752
  return parts.join(',');
1675
1753
  }