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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.5.1](https://github.com/postalsys/imapflow/compare/v2.5.0...v2.5.1) (2026-10-16)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * bound what a hostile server can make the client buffer or wait for, and keep a tagged BYE from passing as success ([9b969a7](https://github.com/postalsys/imapflow/commit/9b969a75090b84e3cbdea408ac4f36035eb2e71b))
9
+
10
+ ## [2.5.0](https://github.com/postalsys/imapflow/compare/v2.4.3...v2.5.0) (2026-10-16)
11
+
12
+
13
+ ### Features
14
+
15
+ * add a transport option and createSocketsTransport() for celld ([f4411aa](https://github.com/postalsys/imapflow/commit/f4411aa55635cb46b0e29a31965816e613d10ab9))
16
+ * add createStreamsTransport() for socket APIs built on web streams ([fec7999](https://github.com/postalsys/imapflow/commit/fec79997db36878a8e3222833837d09f011eac3e))
17
+
3
18
  ## [2.4.3](https://github.com/postalsys/imapflow/compare/v2.4.2...v2.4.3) (2026-10-15)
4
19
 
5
20
 
package/README.md CHANGED
@@ -26,7 +26,65 @@ npm install imapflow
26
26
 
27
27
  ImapFlow requires Node.js 20 or newer. The package ships both an ES module build and a CommonJS build with bundled type declarations, so no separate `@types` package is needed.
28
28
 
29
- The ES module build also runs on [Bun](https://bun.sh/) (tested against the latest release) and on [Cloudflare Workers](https://developers.cloudflare.com/workers/) with the `nodejs_compat` compatibility flag. On Workers connect with implicit TLS (`secure: true`, usually port 993) or in cleartext: the runtime can not upgrade an already connected socket, so a STARTTLS negotiation fails with a TLS error, and it does not allow turning certificate validation off, so `tls: { rejectUnauthorized: false }` is rejected with `ERR_OPTION_NOT_IMPLEMENTED`. COMPRESS=DEFLATE, IDLE and the default pino logger work as on Node.js.
29
+ The ES module build also runs on [Bun](https://bun.sh/) (tested against the latest release) and on [Cloudflare Workers](https://developers.cloudflare.com/workers/) with the `nodejs_compat` compatibility flag. On Workers connect with implicit TLS (`secure: true`, usually port 993) or in cleartext: the runtime can not upgrade an already connected socket, so a STARTTLS negotiation fails with a TLS error, and it does not allow turning certificate validation off, so `tls: { rejectUnauthorized: false }` is rejected with `ERR_OPTION_NOT_IMPLEMENTED`. COMPRESS=DEFLATE, IDLE and the default pino logger work as on Node.js. STARTTLS works on Workers when the connection goes through `createSocketsTransport()` (see below).
30
+
31
+ [celld](https://celld.dev/), the self-hosted runtime for Cloudflare-style Workers, has no `node:net`, `node:tls` or `node:dns`. Pass the `connect()` of `cloudflare:sockets` through the `transport` option:
32
+
33
+ ```js
34
+ import { connect } from 'cloudflare:sockets';
35
+ import { ImapFlow, createSocketsTransport } from 'imapflow';
36
+
37
+ const client = new ImapFlow({
38
+ host: 'imap.example.com',
39
+ port: 993,
40
+ secure: true,
41
+ auth: { user: 'user@example.com', pass: 'secret' },
42
+ transport: createSocketsTransport(connect)
43
+ });
44
+ ```
45
+
46
+ Implicit TLS, STARTTLS, cleartext, IDLE, `download()` and the default logger work. Not available there:
47
+
48
+ - The runtime verifies every certificate against its own root store, so `tls.rejectUnauthorized: false`, a custom `tls.ca`, client certificates and a `servername` that differs from `host` are refused with `UnsupportedTransportOption`. A TLS connection to an IP address needs a certificate issued for that address.
49
+ - COMPRESS=DEFLATE is skipped, the runtime has no streaming zlib.
50
+ - The `proxy` option can not be combined with a transport.
51
+ - `client.tls` stays `false`, the runtime reports no cipher details.
52
+ - A socket is closed when the request (or Durable Object event) that opened it ends. Run the whole session inside the handler, or inside `ctx.waitUntil()` to keep it going after the response.
53
+
54
+ ### Other connections
55
+
56
+ The `transport` option takes any object with a `connect()` method, so other socket APIs plug in the same way. For an API built on web streams (a connection that opens with a promise of a `ReadableStream` and a `WritableStream` of bytes, as `cloudflare:sockets` and Chrome's Direct Sockets do), `createStreamsTransport()` does the wrapping:
57
+
58
+ ```js
59
+ import { ImapFlow, createStreamsTransport } from 'imapflow';
60
+
61
+ const transport = createStreamsTransport({
62
+ // `secure` says whether ImapFlow wants a TLS connection; throw to refuse what the API can not do
63
+ open: ({ host, port, secure }) => {
64
+ const socket = openSocket(host, port, secure);
65
+ return {
66
+ opened: socket.opened.then(() => ({ readable: socket.readable, writable: socket.writable })),
67
+ close: () => socket.close()
68
+ };
69
+ },
70
+ // optional: start TLS on an open connection after STARTTLS. Without it ImapFlow does not try
71
+ // STARTTLS, and with doSTARTTLS: true the connection fails with err.tlsFailed set
72
+ upgrade: (socket, options) => startTlsSomehow(socket)
73
+ });
74
+ ```
75
+
76
+ `opened` may also report `remoteAddress`, `remotePort`, `localAddress` and `localPort`, and for a TLS connection `tls: { authorized, name, version }`, which becomes `client.tls`. If `close()` fails while the streams are locked, it is called again with the locks released.
77
+
78
+ An API built on events instead implements the transport itself: `connect(options, onConnect)` returns a Node.js `Duplex` stream of bytes and calls `onConnect` once the connection is up (or throws to refuse it), and an optional `upgrade(socket, options, onSecure)` starts TLS. The Duplex also needs `setTimeout(ms)`, emitting `timeout` after `ms` without traffic like `net.Socket` does: it is the watchdog behind the `socketTimeout` option.
79
+
80
+ In a browser, ImapFlow runs from the ES module build when the bundler provides what Node.js has and browsers do not:
81
+
82
+ - `node:stream` from [readable-stream](https://www.npmjs.com/package/readable-stream), `node:events` from [events](https://www.npmjs.com/package/events), and the `Buffer` global from [buffer](https://www.npmjs.com/package/buffer)
83
+ - the `process` global from [process](https://www.npmjs.com/package/process), and `setImmediate()` and `clearImmediate()` globals
84
+ - `node:crypto` with an MD5 `createHash()` (message ids on servers without OBJECTID), and `path` with `parse()` (used by a dependency for attachment file names)
85
+ - empty modules for `node:net`, `node:tls`, `node:dns` and `node:zlib` (COMPRESS is then skipped)
86
+
87
+ `test/browser/` in the repository has the complete esbuild setup, with the shims, and runs it in headless Chrome.
30
88
 
31
89
  ## Quick Example
32
90
 
@@ -2,6 +2,35 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.default = authenticate;
4
4
  const tools_js_1 = require("../tools.js");
5
+ const errors_js_1 = require("../errors.js");
6
+ // The OAuth SASL payloads are framed with \x01 (RFC 7628 section 3.1, and the XOAUTH2 format
7
+ // copies it), so a control character in a value ends the field early and the rest of the value
8
+ // becomes fields of its own, auth= included. RFC 7628 allows only VCHAR, SP, HTAB, CR and LF in
9
+ // a value, and none of the controls has a place in a username or a bearer token.
10
+ const RE_CONTROL_CHARS = /[\x00-\x1f\x7f]/;
11
+ /**
12
+ * Refuses a username or token that could not be framed as one SASL field.
13
+ *
14
+ * @param value - The value about to be placed in the payload
15
+ * @param what - What the value is, for the error message
16
+ * @throws If the value contains a control character
17
+ */
18
+ function assertSaslValue(value, what) {
19
+ if (RE_CONTROL_CHARS.test(value)) {
20
+ throw (0, errors_js_1.createImapError)(`The ${what} contains control characters`, 'InvalidStringValue');
21
+ }
22
+ }
23
+ /**
24
+ * Escapes a value for the authzid of a GS2 header (RFC 5801 section 4): "," and "=" delimit
25
+ * the header, so they are written as =2C and =3D. "=" goes first, so the "=" the escaping
26
+ * itself introduces is not escaped again.
27
+ *
28
+ * @param value - The saslname
29
+ * @returns The escaped saslname
30
+ */
31
+ function escapeSaslName(value) {
32
+ return value.replace(/[=]/g, '=3D').replace(/,/g, '=2C');
33
+ }
5
34
  /**
6
35
  * Handles authentication errors by enriching the error object with server response details.
7
36
  *
@@ -37,6 +66,8 @@ async function authOauth(connection, username, accessToken) {
37
66
  let oauthbearer;
38
67
  let command;
39
68
  let breaker;
69
+ assertSaslValue(username, 'username');
70
+ assertSaslValue(accessToken, 'access token');
40
71
  if (connection.capabilities.has('AUTH=OAUTHBEARER')) {
41
72
  // OAUTHBEARER payload per RFC 7628: fields separated by \x01 (SASL GS2 framing).
42
73
  // Format: "n,a=<user>," \x01 "host=..." \x01 "port=..." \x01 "auth=Bearer <token>" \x01 \x01
@@ -47,7 +78,7 @@ async function authOauth(connection, username, accessToken) {
47
78
  // A server validating either field rejects with status `invalid_request` - a permanent
48
79
  // failure that refreshing the access token can never clear.
49
80
  oauthbearer = [
50
- `n,a=${username},`,
81
+ `n,a=${escapeSaslName(username)},`,
51
82
  `host=${connection.servername || connection.host}`,
52
83
  `port=${connection.port}`,
53
84
  `auth=Bearer ${accessToken}`,
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.default = fetch;
4
4
  const tools_js_1 = require("../tools.js");
5
+ const errors_js_1 = require("../errors.js");
5
6
  /**
6
7
  * Fetches emails from the server.
7
8
  *
@@ -38,9 +39,10 @@ async function fetch(connection, range, query, options) {
38
39
  count: 0,
39
40
  list: []
40
41
  };
41
- // The first error the onUntaggedFetch consumer reported through next(err). Errors thrown
42
- // by untagged handlers are only logged by the connection, so it is kept here and fails
43
- // the command once the FETCH completes; later messages are no longer handed to the consumer.
42
+ // The first error the onUntaggedFetch consumer reported through next(err), or the row
43
+ // limit being reached. Errors thrown by untagged handlers are only logged by the
44
+ // connection, so it is kept here and fails the command once the FETCH completes; later
45
+ // messages are no longer handed to the consumer or collected.
44
46
  let consumerError = null;
45
47
  let response;
46
48
  try {
@@ -235,6 +237,11 @@ async function fetch(connection, range, query, options) {
235
237
  consumerError = err;
236
238
  }
237
239
  }
240
+ else if (messages.list.length >= tools_js_1.MAX_COLLECTED_RESPONSES) {
241
+ // The rows are the server's to send and the memory holding them is
242
+ // this process's, see MAX_COLLECTED_RESPONSES
243
+ consumerError = (0, errors_js_1.createImapError)(`FETCH returned more than ${tools_js_1.MAX_COLLECTED_RESPONSES} messages`, 'TooManyResponses');
244
+ }
238
245
  else {
239
246
  messages.list.push(formatted);
240
247
  }
@@ -40,6 +40,12 @@ async function runIdle(connection) {
40
40
  // connection, so a newer IDLE session that already installed its own is left alone.
41
41
  let ownPreCheck = null;
42
42
  let releaseIdling = claimIdling(connection);
43
+ // The deadline for the server's "+" continuation, see the onSend handler below
44
+ let continuationTimer = null;
45
+ let clearContinuationTimer = () => {
46
+ (0, tools_js_1.clearTimer)(continuationTimer);
47
+ continuationTimer = null;
48
+ };
43
49
  try {
44
50
  // State flags for the IDLE lifecycle:
45
51
  // - doneRequested: someone wants to break IDLE (e.g., to run another command)
@@ -105,6 +111,7 @@ async function runIdle(connection) {
105
111
  // After this, the server will push untagged responses for mailbox changes.
106
112
  // We can now safely send DONE if a break was already requested.
107
113
  onPlusTag: async () => {
114
+ clearContinuationTimer();
108
115
  connection.log.debug({
109
116
  msg: `Initiated IDLE, waiting for server input`,
110
117
  lockId: connection.currentLock ? connection.currentLock.lockId : undefined,
@@ -121,7 +128,29 @@ async function runIdle(connection) {
121
128
  }
122
129
  }
123
130
  },
124
- onSend: () => { }
131
+ // The server has socketTimeout from the moment IDLE is on the wire to acknowledge it
132
+ // with the "+". The socket watchdog only covers a server that goes quiet: one that
133
+ // keeps sending untagged lines without ever answering "+" kept the IDLE in flight for
134
+ // good, and every later command queued behind it. DONE can not be sent before the
135
+ // "+" (RFC 2177 section 3), so there is no way to break such an IDLE and fall back
136
+ // to polling; the connection is failed the way the watchdog fails a dead peer.
137
+ onSend: () => {
138
+ continuationTimer = (0, tools_js_1.unrefTimer)(setTimeout(() => {
139
+ continuationTimer = null;
140
+ if (canEnd || connection.isClosed) {
141
+ return;
142
+ }
143
+ connection.log.error({
144
+ msg: 'Server did not acknowledge IDLE, closing the connection',
145
+ timeout: connection.socketTimeout,
146
+ cid: connection.id
147
+ });
148
+ connection.emitError((0, errors_js_1.createImapError)('IDLE was not acknowledged within the socket timeout', 'ETIMEOUT', {
149
+ command: 'IDLE',
150
+ details: { socketTimeout: connection.socketTimeout }
151
+ }));
152
+ }, connection.socketTimeout));
153
+ }
125
154
  });
126
155
  response.next();
127
156
  return;
@@ -153,6 +182,7 @@ async function runIdle(connection) {
153
182
  // (including a server-terminated IDLE, where preCheck never ran), rejected command,
154
183
  // parser failure and connection close. `idling` drives socket-timeout handling, so it
155
184
  // must always describe reality rather than being left over from a previous state.
185
+ clearContinuationTimer();
156
186
  releaseIdling();
157
187
  if (connection.preCheck === ownPreCheck) {
158
188
  connection.preCheck = false;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.default = list;
4
4
  const tools_js_1 = require("../tools.js");
5
+ const errors_js_1 = require("../errors.js");
5
6
  const status_fields_js_1 = require("./status-fields.js");
6
7
  const special_use_js_1 = require("../special-use.js");
7
8
  /**
@@ -140,6 +141,8 @@ async function list(connection, reference, mailbox, options) {
140
141
  // namespace prefix was used (INBOX may live outside the namespace).
141
142
  let runList = async (reference, mailbox, returnArgs) => {
142
143
  const cmdArgs = [(0, tools_js_1.encodePath)(connection, reference), (0, tools_js_1.encodePath)(connection, mailbox)];
144
+ // Set once the entry limit is reached, fails the command when it completes
145
+ let overflow = null;
143
146
  if (returnArgs.length) {
144
147
  cmdArgs.push({ type: 'ATOM', value: 'RETURN' }, returnArgs);
145
148
  }
@@ -199,6 +202,14 @@ async function list(connection, reference, mailbox, options) {
199
202
  if (specialUseFlag && (!isNameSource(flagSource) || !entry.flags.has('\\NonExistent'))) {
200
203
  addSpecialUseMatch(entry, specialUseFlag, flagSource);
201
204
  }
205
+ if (entries.length >= tools_js_1.MAX_COLLECTED_RESPONSES) {
206
+ // The entries are the server's to send and the memory holding them
207
+ // is this process's, see MAX_COLLECTED_RESPONSES. A throw from an
208
+ // untagged handler is only logged, so the error is raised below,
209
+ // once the command completes
210
+ overflow = overflow || (0, errors_js_1.createImapError)(`LIST returned more than ${tools_js_1.MAX_COLLECTED_RESPONSES} mailboxes`, 'TooManyResponses');
211
+ return;
212
+ }
202
213
  entries.push(entry);
203
214
  },
204
215
  // Inline STATUS response from LIST-STATUS extension (RFC 5819).
@@ -218,6 +229,9 @@ async function list(connection, reference, mailbox, options) {
218
229
  }
219
230
  });
220
231
  response.next();
232
+ if (overflow) {
233
+ throw overflow;
234
+ }
221
235
  };
222
236
  let normalizedReference = (0, tools_js_1.normalizePath)(connection, reference || '');
223
237
  let normalizedMailbox = (0, tools_js_1.normalizePath)(connection, mailbox || '', true);
@@ -35,6 +35,7 @@ export declare const ImapFlowErrorCode: {
35
35
  readonly LineTooLarge: "LineTooLarge";
36
36
  readonly LiteralTooLarge: "LiteralTooLarge";
37
37
  readonly ResponseTooLarge: "ResponseTooLarge";
38
+ readonly TooManyResponses: "TooManyResponses";
38
39
  readonly InvalidStringValue: "InvalidStringValue";
39
40
  readonly InvalidTokenValue: "InvalidTokenValue";
40
41
  readonly InvalidTextValue: "InvalidTextValue";
@@ -47,6 +48,7 @@ export declare const ImapFlowErrorCode: {
47
48
  readonly EPROXY: "EPROXY";
48
49
  readonly UnsupportedProxyAddress: "UnsupportedProxyAddress";
49
50
  readonly ERR_INVALID_URL: "ERR_INVALID_URL";
51
+ readonly UnsupportedTransportOption: "UnsupportedTransportOption";
50
52
  readonly InstanceReused: "InstanceReused";
51
53
  };
52
54
  /** One of the {@link ImapFlowErrorCode} values */
@@ -43,6 +43,8 @@ exports.ImapFlowErrorCode = {
43
43
  LineTooLarge: 'LineTooLarge',
44
44
  LiteralTooLarge: 'LiteralTooLarge',
45
45
  ResponseTooLarge: 'ResponseTooLarge',
46
+ // one command received more untagged responses than are kept, see MAX_COLLECTED_RESPONSES
47
+ TooManyResponses: 'TooManyResponses',
46
48
  // invalid values in a command
47
49
  InvalidStringValue: 'InvalidStringValue',
48
50
  InvalidTokenValue: 'InvalidTokenValue',
@@ -58,6 +60,8 @@ exports.ImapFlowErrorCode = {
58
60
  EPROXY: 'EPROXY',
59
61
  UnsupportedProxyAddress: 'UnsupportedProxyAddress',
60
62
  ERR_INVALID_URL: 'ERR_INVALID_URL',
63
+ // the transport option
64
+ UnsupportedTransportOption: 'UnsupportedTransportOption',
61
65
  // API misuse
62
66
  InstanceReused: 'InstanceReused'
63
67
  };
@@ -87,6 +87,9 @@ async function parser(command, options) {
87
87
  if (parserInstance.remainder.trim().length) {
88
88
  await parserInstance.getSpace();
89
89
  response.attributes = await parserInstance.getAttributes();
90
+ if (parserInstance.truncatedAttributes && parserInstance.truncatedAttributes.length) {
91
+ response.truncatedAttributes = parserInstance.truncatedAttributes;
92
+ }
90
93
  }
91
94
  if (parserInstance.humanReadable) {
92
95
  response.attributes = (response.attributes || []).concat({
@@ -12,6 +12,8 @@ export declare class ParserInstance {
12
12
  tag?: string | undefined;
13
13
  command?: string | undefined;
14
14
  humanReadable?: string | undefined;
15
+ /** The keys of the FETCH data items the token parser dropped for exceeding the nesting limit */
16
+ truncatedAttributes?: string[] | undefined;
15
17
  /**
16
18
  * Creates a new ParserInstance for parsing an IMAP response line.
17
19
  *
@@ -217,7 +217,9 @@ class ParserInstance {
217
217
  });
218
218
  }
219
219
  const tokenParser = new token_parser_js_1.TokenParser(this, this.pos, this.remainder, this.options);
220
- return await tokenParser.getAttributes();
220
+ let attributes = await tokenParser.getAttributes();
221
+ this.truncatedAttributes = tokenParser.truncatedAttributes;
222
+ return attributes;
221
223
  }
222
224
  }
223
225
  exports.ParserInstance = ParserInstance;
@@ -22,6 +22,8 @@ export interface TokenNode {
22
22
  started?: boolean | undefined;
23
23
  chBuffer?: Buffer | undefined;
24
24
  chPos?: number | undefined;
25
+ /** Set on a FETCH data item whose nesting exceeded MAX_NODE_DEPTH, see createNode() */
26
+ truncated?: boolean | undefined;
25
27
  }
26
28
  /**
27
29
  * The parent object a TokenParser reads the parsed command from
@@ -34,7 +36,8 @@ export interface TokenParserParent {
34
36
  * Handles all IMAP data types: atoms, quoted strings, literals (including literal8),
35
37
  * sequences, lists (parenthesized groups), sections (bracketed groups), and partial ranges.
36
38
  * Enforces a maximum nesting depth of {@link MAX_NODE_DEPTH} to prevent stack overflow
37
- * from malicious input.
39
+ * from malicious input: a FETCH data item that nests deeper is replaced by NIL and listed in
40
+ * `truncatedAttributes`, any other input that nests deeper fails to parse.
38
41
  */
39
42
  export declare class TokenParser {
40
43
  str: string;
@@ -45,6 +48,8 @@ export declare class TokenParser {
45
48
  currentNode: TokenNode;
46
49
  pos: number;
47
50
  state: number;
51
+ /** The keys of the FETCH data items that were dropped for exceeding MAX_NODE_DEPTH */
52
+ truncatedAttributes: string[];
48
53
  expectedLiteralType?: string | false | undefined;
49
54
  /**
50
55
  * Creates a new TokenParser.
@@ -88,9 +93,23 @@ export declare class TokenParser {
88
93
  * @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
89
94
  * @param startPos - The starting position of this node in the original input string.
90
95
  * @returns The newly created node with childNodes, type, value, and isClosed properties.
91
- * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH.
96
+ * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item.
92
97
  */
93
98
  createNode(parentNode?: TokenNode | undefined, startPos?: number | undefined): TokenNode;
99
+ /**
100
+ * Whether the line being parsed is a message data response (`* 12 FETCH (...)`): a numeric
101
+ * command with FETCH as its first attribute. Only those get the per-item nesting fallback.
102
+ *
103
+ * @returns True for an untagged FETCH response
104
+ */
105
+ isFetchResponse(): boolean;
106
+ /**
107
+ * Marks the FETCH data item a too deeply nested node belongs to as truncated and records
108
+ * its key (the item before it in the list, e.g. BODYSTRUCTURE) for the caller to report.
109
+ *
110
+ * @param node - The node at MAX_NODE_DEPTH whose child would exceed the limit
111
+ */
112
+ truncateFetchDataItem(node: TokenNode): void;
94
113
  /**
95
114
  * Processes the entire input string character by character using a state machine.
96
115
  * Transitions between states (NORMAL, ATOM, STRING, LITERAL, SEQUENCE, PARTIAL, TEXT)
@@ -15,14 +15,23 @@ const STATE_PARTIAL = 0x004;
15
15
  const STATE_SEQUENCE = 0x005;
16
16
  const STATE_STRING = 0x006;
17
17
  const RE_SINGLE_DIGIT = /^\d$/;
18
- // Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...))))))
19
- const MAX_NODE_DEPTH = 25;
18
+ // Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...)))))).
19
+ // The tokenizer itself is iterative; what recurses is the getAttributes() walk and the
20
+ // consumers of its output (the body structure parser, the compiler used for logging), and
21
+ // a hundred frames is nothing to any of them. The old bound of 25 was reached by a message
22
+ // with 22 nested multiparts, which an external sender can produce, and the whole FETCH row
23
+ // then failed to parse and was dropped silently.
24
+ const MAX_NODE_DEPTH = 100;
25
+ // The depth at which the data items of a FETCH response sit: TREE (0) > the item list (1) >
26
+ // a key or value (2). See createNode() for what happens when nesting overflows below one.
27
+ const FETCH_DATA_ITEM_DEPTH = 2;
20
28
  /**
21
29
  * Tokenizes an IMAP attribute string into a tree of typed nodes.
22
30
  * Handles all IMAP data types: atoms, quoted strings, literals (including literal8),
23
31
  * sequences, lists (parenthesized groups), sections (bracketed groups), and partial ranges.
24
32
  * Enforces a maximum nesting depth of {@link MAX_NODE_DEPTH} to prevent stack overflow
25
- * from malicious input.
33
+ * from malicious input: a FETCH data item that nests deeper is replaced by NIL and listed in
34
+ * `truncatedAttributes`, any other input that nests deeper fails to parse.
26
35
  */
27
36
  class TokenParser {
28
37
  /**
@@ -44,6 +53,7 @@ class TokenParser {
44
53
  // Same normalization and default as the streaming parser, so a direct user of this parser
45
54
  // gets the same bound (an explicit 0 means "reject any non-empty inline literal").
46
55
  this.maxLiteralSize = (0, limits_js_1.normalizeLimit)(this.options.maxLiteralSize, limits_js_1.MAX_LITERAL_SIZE);
56
+ this.truncatedAttributes = [];
47
57
  this.tree = this.currentNode = this.createNode();
48
58
  this.pos = startPos || 0;
49
59
  this.currentNode.type = 'TREE';
@@ -92,6 +102,16 @@ class TokenParser {
92
102
  throw this.parserError(`Unexpected end of input at position ${this.pos + this.str.length - 1} [E9]`, 'ParserError9', this.pos + this.str.length - 1);
93
103
  }
94
104
  let type = (node.type || '').toString().toUpperCase();
105
+ if (node.truncated) {
106
+ // The item nested deeper than MAX_NODE_DEPTH, see createNode(). Its subtree is
107
+ // not walked (that is what keeps this recursion bounded). A list is replaced by
108
+ // NIL so the data items keep pairing up as key and value; a section is attached
109
+ // to the item before it, so it is dropped whole.
110
+ if (type === 'LIST') {
111
+ branch.push(null);
112
+ }
113
+ return;
114
+ }
95
115
  switch (type) {
96
116
  case 'LITERAL':
97
117
  case 'STRING':
@@ -142,7 +162,7 @@ class TokenParser {
142
162
  * @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
143
163
  * @param startPos - The starting position of this node in the original input string.
144
164
  * @returns The newly created node with childNodes, type, value, and isClosed properties.
145
- * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH.
165
+ * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item.
146
166
  */
147
167
  createNode(parentNode, startPos) {
148
168
  let node = {
@@ -160,8 +180,20 @@ class TokenParser {
160
180
  node.depth = 0;
161
181
  }
162
182
  if (node.depth > MAX_NODE_DEPTH) {
163
- // A bounded prefix only, the error travels whole into logs and rejections
164
- throw (0, errors_js_1.createImapError)('Too much nesting in IMAP string', 'MAX_IMAP_NESTING_REACHED', { _imapStr: this.str.slice(0, limits_js_1.ERROR_CONTEXT_LENGTH) });
183
+ if (!this.isFetchResponse()) {
184
+ // A bounded prefix only, the error travels whole into logs and rejections
185
+ throw (0, errors_js_1.createImapError)('Too much nesting in IMAP string', 'MAX_IMAP_NESTING_REACHED', { _imapStr: this.str.slice(0, limits_js_1.ERROR_CONTEXT_LENGTH) });
186
+ }
187
+ // A FETCH row carries the data of one message, and the item nesting this deep (a
188
+ // BODYSTRUCTURE of a message with a hundred nested multiparts, which the sender of
189
+ // that message controls) is one of several. Failing the line would drop the whole
190
+ // row, and with it the message, from the FETCH result. The data item is marked
191
+ // instead, which getAttributes() turns into NIL, and the tokenizer carries on: it
192
+ // keeps no stack, so the depth costs it nothing. Marked on the first crossing only,
193
+ // every node below that one is already inside a marked item.
194
+ if (parentNode.depth === MAX_NODE_DEPTH) {
195
+ this.truncateFetchDataItem(parentNode);
196
+ }
165
197
  }
166
198
  if (typeof startPos === 'number') {
167
199
  node.startPos = startPos;
@@ -171,6 +203,44 @@ class TokenParser {
171
203
  }
172
204
  return node;
173
205
  }
206
+ /**
207
+ * Whether the line being parsed is a message data response (`* 12 FETCH (...)`): a numeric
208
+ * command with FETCH as its first attribute. Only those get the per-item nesting fallback.
209
+ *
210
+ * @returns True for an untagged FETCH response
211
+ */
212
+ isFetchResponse() {
213
+ let command = this.parent.command;
214
+ let first = this.tree.childNodes[0];
215
+ return (typeof command === 'string' &&
216
+ /^\d+$/.test(command) &&
217
+ !!first &&
218
+ first.type === 'ATOM' &&
219
+ typeof first.value === 'string' &&
220
+ first.value.toUpperCase() === 'FETCH');
221
+ }
222
+ /**
223
+ * Marks the FETCH data item a too deeply nested node belongs to as truncated and records
224
+ * its key (the item before it in the list, e.g. BODYSTRUCTURE) for the caller to report.
225
+ *
226
+ * @param node - The node at MAX_NODE_DEPTH whose child would exceed the limit
227
+ */
228
+ truncateFetchDataItem(node) {
229
+ let item = node;
230
+ while (item.depth > FETCH_DATA_ITEM_DEPTH && item.parentNode) {
231
+ item = item.parentNode;
232
+ }
233
+ if (item.truncated) {
234
+ return;
235
+ }
236
+ item.truncated = true;
237
+ // The item is the last child of the list while it is being parsed, so its key is the
238
+ // child before it. A key is an atom (BODYSTRUCTURE, ENVELOPE, BODY); anything else means
239
+ // the row itself is malformed, and the key is reported as unknown.
240
+ let siblings = item.parentNode ? item.parentNode.childNodes : [];
241
+ let key = siblings[siblings.length - 2];
242
+ this.truncatedAttributes.push(key && key.type === 'ATOM' && typeof key.value === 'string' ? key.value.toUpperCase() : '?');
243
+ }
174
244
  /**
175
245
  * Processes the entire input string character by character using a state machine.
176
246
  * Transitions between states (NORMAL, ATOM, STRING, LITERAL, SEQUENCE, PARTIAL, TEXT)
@@ -39,6 +39,11 @@ export interface ImapResponse {
39
39
  attributes?: ImapAttributeList | undefined;
40
40
  /** Number of leading NUL bytes stripped from the line, if any */
41
41
  nullBytesRemoved?: number | undefined;
42
+ /**
43
+ * Keys of the FETCH data items (e.g. BODYSTRUCTURE) that were replaced by NIL because they
44
+ * nested deeper than the parser's limit, if any
45
+ */
46
+ truncatedAttributes?: string[] | undefined;
42
47
  }
43
48
  /**
44
49
  * What the command compiler accepts as a node: parsed tokens and lists, and also bare
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * @module imapflow
3
3
  */
4
- import tls from 'node:tls';
5
- import net from 'node:net';
6
4
  import { EventEmitter } from 'node:events';
7
5
  import { PassThrough, type Readable } from 'node:stream';
8
6
  import { AuthenticationFailure } from './errors.js';
9
- import type { AppendResponseObject, CopyResponseObject, DownloadManyOptions, DownloadManyResult, DownloadObject, DownloadNotFound, DownloadOptions, ESearchResult, FetchMessageObject, FetchOptions, FetchQueryObject, IdInfoObject, ImapFlowEvents, ImapFlowOptions, InternalLogger, ListOptions, ListResponse, ListTreeResponse, MailboxCreateResponse, MailboxDeleteResponse, MailboxLockObject, MailboxLockOptions, MailboxObject, MailboxOpenOptions, MailboxRenameResponse, MessageRange, MessageRangeOptions, NamespaceObject, NamespacesObject, QuotaResponse, SearchObject, SearchOptions, SearchReturnOption, SequenceString, StatusObject, StatusQuery, StoreOptions, TlsInfo } from './types.js';
7
+ import type { AppendResponseObject, CopyResponseObject, DownloadManyOptions, DownloadManyResult, DownloadObject, DownloadNotFound, DownloadOptions, ESearchResult, FetchMessageObject, FetchOptions, FetchQueryObject, IdInfoObject, ImapFlowEvents, ImapFlowOptions, InternalLogger, ListOptions, ListResponse, ListTreeResponse, MailboxCreateResponse, MailboxDeleteResponse, MailboxLockObject, MailboxLockOptions, MailboxObject, MailboxOpenOptions, MailboxRenameResponse, MessageRange, MessageRangeOptions, NamespaceObject, NamespacesObject, QuotaResponse, SearchObject, SearchOptions, SearchReturnOption, SequenceString, StatusObject, StatusQuery, StoreOptions, TlsInfo, ImapTransportSocket } from './types.js';
10
8
  export type * from './types.js';
11
9
  export type { ImapFlowError } from './errors.js';
12
10
  export { AuthenticationFailure, ImapFlowErrorCode } from './errors.js';
11
+ export { createSocketsTransport } from './sockets-transport.js';
12
+ export type { SocketsApiSocket, SocketsConnect } from './sockets-transport.js';
13
+ export { createStreamsTransport } from './streams-transport.js';
14
+ export type { StreamsReader, StreamsSocket, StreamsSocketInfo, StreamsTlsInfo, StreamsTransportApi, StreamsWriter } from './streams-transport.js';
13
15
  export type { ImapAttribute, ImapAttributeList, ImapAttributeNode, ImapResponse } from './handler/types.js';
14
16
  declare const stateValues: {
15
17
  readonly NOT_AUTHENTICATED: 1;
@@ -27,13 +29,11 @@ export type ConnectionStates = {
27
29
  readonly [K in keyof typeof stateValues]: ConnectionState;
28
30
  };
29
31
  /**
30
- * The socket carrying the IMAP session. A TLS socket exposes `getCipher()` and `authorized`, a
31
- * cleartext socket does not, and the code checks for them at runtime.
32
+ * The socket carrying the IMAP session: a `net.Socket` or `tls.TLSSocket` on Node.js, or what a
33
+ * transport returns. Typed as {@link ImapTransportSocket}, so the client only relies on what
34
+ * every transport provides and checks for the optional members at runtime.
32
35
  */
33
- export type ImapSocket = net.Socket & {
34
- getCipher?: (() => tls.CipherNameAndProtocol) | undefined;
35
- authorized?: boolean | undefined;
36
- };
36
+ export type ImapSocket = ImapTransportSocket;
37
37
  /**
38
38
  * Where outgoing bytes are written: the socket itself, or the PassThrough that feeds the
39
39
  * DEFLATE stream once compression is active