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
@@ -1,5 +1,5 @@
1
1
  import type { ConnectionOptions, CipherNameAndProtocol } from 'node:tls';
2
- import type { Readable } from 'node:stream';
2
+ import type { Duplex, Readable } from 'node:stream';
3
3
  /**
4
4
  * Logger interface. Any object with `debug`, `info`, `warn` and `error` methods can be used,
5
5
  * for example a pino or bunyan instance. `trace` and `fatal` are used when present.
@@ -95,6 +95,14 @@ export interface ImapFlowOptions {
95
95
  * Node as-is. Proxy DNS and negotiation run inside `connectionTimeout`.
96
96
  */
97
97
  proxy?: string | undefined;
98
+ /**
99
+ * Opens the connection through this transport instead of `node:net` and `node:tls`, for
100
+ * runtimes without those modules. `createSocketsTransport()` builds one from the
101
+ * `connect()` of the `cloudflare:sockets` API (celld, Cloudflare Workers),
102
+ * `createStreamsTransport()` from any socket API built on web streams. Can not be combined
103
+ * with `proxy`.
104
+ */
105
+ transport?: ImapTransport | undefined;
98
106
  /** If true, then use QRESYNC instead of CONDSTORE. EXPUNGE notifications will include UID instead of sequence number */
99
107
  qresync?: boolean | undefined;
100
108
  /** If set, then breaks and restarts IDLE every maxIdleTime ms */
@@ -162,7 +170,8 @@ export interface ImapFlowOptions {
162
170
  * when the server offers STARTTLS and otherwise continues in cleartext with a warning logged:
163
171
  * the capability list that decides this was itself received in cleartext, so an attacker on
164
172
  * the path can strip it. Set `doSTARTTLS: true` whenever the credentials must never cross the
165
- * 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.
166
175
  */
167
176
  doSTARTTLS?: boolean | undefined;
168
177
  /** Custom instance ID string for logs */
@@ -882,3 +891,70 @@ export interface ImapFlowEvents {
882
891
  /** The server sent an ALERT: text meant for the user, such as a planned shutdown or an account problem */
883
892
  alert: [alert: AlertEvent];
884
893
  }
894
+ /** Where {@link ImapTransport} connects to, and the `tls` options of the client */
895
+ export interface ImapTransportOptions {
896
+ host: string;
897
+ port: number;
898
+ /** The SNI name, absent for an IP literal host without the `servername` option */
899
+ servername?: string | undefined;
900
+ /** The `tls` option of the client */
901
+ tls?: ConnectionOptions | undefined;
902
+ }
903
+ /** What {@link ImapTransport.connect} gets: the destination, and whether to open a TLS connection */
904
+ export interface ImapTransportConnectOptions extends ImapTransportOptions {
905
+ /** Open a TLS connection (the `secure` option), otherwise a cleartext one */
906
+ secure: boolean;
907
+ }
908
+ /**
909
+ * The socket a {@link ImapTransport} returns: a Duplex stream of Buffers that emits `error`,
910
+ * `end` and `close`, and `timeout` once `setTimeout(ms)` was called and `ms` passed without
911
+ * traffic (0 disables it, like `net.Socket#setTimeout()`), the watchdog behind the
912
+ * `socketTimeout` option. The rest of what a `net.Socket` has is optional; ImapFlow uses it when
913
+ * it is there (the addresses for logging, the cipher of a TLS connection for `client.tls`).
914
+ */
915
+ export type ImapTransportSocket = Duplex & {
916
+ setTimeout(ms: number): unknown;
917
+ setKeepAlive?: ((enable?: boolean, initialDelay?: number) => unknown) | undefined;
918
+ remoteAddress?: string | undefined;
919
+ remotePort?: number | undefined;
920
+ localAddress?: string | undefined;
921
+ localPort?: number | undefined;
922
+ getCipher?: (() => CipherNameAndProtocol | null) | undefined;
923
+ authorized?: boolean | undefined;
924
+ };
925
+ /**
926
+ * Opens and upgrades the socket carrying the session, see the `transport` option.
927
+ * `createStreamsTransport()` builds one over any socket API built on web streams,
928
+ * `createSocketsTransport()` over `cloudflare:sockets`.
929
+ */
930
+ export interface ImapTransport {
931
+ /**
932
+ * Opens a connection, cleartext or TLS. Connection errors are emitted on the returned
933
+ * socket; a connection refused up front (an option the transport can not honor) may throw.
934
+ *
935
+ * @param options - Destination, TLS options and whether to open a TLS connection
936
+ * @param onConnect - Called once the connection (and the TLS handshake) is established
937
+ */
938
+ connect(options: ImapTransportConnectOptions, onConnect: () => void): ImapTransportSocket;
939
+ /**
940
+ * Called right before the STARTTLS command is sent on the cleartext socket, so a transport
941
+ * that can only start TLS without a read in flight can stop reading once it is answered.
942
+ * STARTTLS is the only command in flight then: the next tagged response line (one that does
943
+ * not start with `* ` or `+ `) answers it, and if it is an OK, the server sends nothing more
944
+ * before the TLS handshake.
945
+ *
946
+ * @param socket - The cleartext socket returned by connect()
947
+ */
948
+ prepareUpgrade?: ((socket: ImapTransportSocket) => void) | undefined;
949
+ /**
950
+ * Starts TLS on a connected cleartext socket after STARTTLS. May throw if the upgrade is
951
+ * refused before any handshake, handshake errors are emitted on the returned socket. A
952
+ * transport that can not start TLS leaves it out: ImapFlow then treats the server as one
953
+ * without STARTTLS (see the `doSTARTTLS` option).
954
+ *
955
+ * @param socket - The cleartext socket returned by connect()
956
+ * @param options - Destination and TLS options
957
+ * @param onSecure - Called once the TLS handshake is complete
958
+ */
959
+ upgrade?: ((socket: ImapTransportSocket, options: ImapTransportOptions, onSecure: () => void) => ImapTransportSocket) | undefined;
960
+ }
@@ -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";
@@ -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 */
@@ -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',
@@ -54,6 +56,8 @@ export const ImapFlowErrorCode = {
54
56
  EPROXY: 'EPROXY',
55
57
  UnsupportedProxyAddress: 'UnsupportedProxyAddress',
56
58
  ERR_INVALID_URL: 'ERR_INVALID_URL',
59
+ // the transport option
60
+ UnsupportedTransportOption: 'UnsupportedTransportOption',
57
61
  // API misuse
58
62
  InstanceReused: 'InstanceReused'
59
63
  };
@@ -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)
@@ -9,14 +9,23 @@ const STATE_PARTIAL = 0x004;
9
9
  const STATE_SEQUENCE = 0x005;
10
10
  const STATE_STRING = 0x006;
11
11
  const RE_SINGLE_DIGIT = /^\d$/;
12
- // Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...))))))
13
- const MAX_NODE_DEPTH = 25;
12
+ // Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...)))))).
13
+ // The tokenizer itself is iterative; what recurses is the getAttributes() walk and the
14
+ // consumers of its output (the body structure parser, the compiler used for logging), and
15
+ // a hundred frames is nothing to any of them. The old bound of 25 was reached by a message
16
+ // with 22 nested multiparts, which an external sender can produce, and the whole FETCH row
17
+ // then failed to parse and was dropped silently.
18
+ const MAX_NODE_DEPTH = 100;
19
+ // The depth at which the data items of a FETCH response sit: TREE (0) > the item list (1) >
20
+ // a key or value (2). See createNode() for what happens when nesting overflows below one.
21
+ const FETCH_DATA_ITEM_DEPTH = 2;
14
22
  /**
15
23
  * Tokenizes an IMAP attribute string into a tree of typed nodes.
16
24
  * Handles all IMAP data types: atoms, quoted strings, literals (including literal8),
17
25
  * sequences, lists (parenthesized groups), sections (bracketed groups), and partial ranges.
18
26
  * Enforces a maximum nesting depth of {@link MAX_NODE_DEPTH} to prevent stack overflow
19
- * from malicious input.
27
+ * from malicious input: a FETCH data item that nests deeper is replaced by NIL and listed in
28
+ * `truncatedAttributes`, any other input that nests deeper fails to parse.
20
29
  */
21
30
  export class TokenParser {
22
31
  /**
@@ -38,6 +47,7 @@ export class TokenParser {
38
47
  // Same normalization and default as the streaming parser, so a direct user of this parser
39
48
  // gets the same bound (an explicit 0 means "reject any non-empty inline literal").
40
49
  this.maxLiteralSize = normalizeLimit(this.options.maxLiteralSize, MAX_LITERAL_SIZE);
50
+ this.truncatedAttributes = [];
41
51
  this.tree = this.currentNode = this.createNode();
42
52
  this.pos = startPos || 0;
43
53
  this.currentNode.type = 'TREE';
@@ -86,6 +96,16 @@ export class TokenParser {
86
96
  throw this.parserError(`Unexpected end of input at position ${this.pos + this.str.length - 1} [E9]`, 'ParserError9', this.pos + this.str.length - 1);
87
97
  }
88
98
  let type = (node.type || '').toString().toUpperCase();
99
+ if (node.truncated) {
100
+ // The item nested deeper than MAX_NODE_DEPTH, see createNode(). Its subtree is
101
+ // not walked (that is what keeps this recursion bounded). A list is replaced by
102
+ // NIL so the data items keep pairing up as key and value; a section is attached
103
+ // to the item before it, so it is dropped whole.
104
+ if (type === 'LIST') {
105
+ branch.push(null);
106
+ }
107
+ return;
108
+ }
89
109
  switch (type) {
90
110
  case 'LITERAL':
91
111
  case 'STRING':
@@ -136,7 +156,7 @@ export class TokenParser {
136
156
  * @param parentNode - The parent node to attach this node to. If omitted, creates a root node.
137
157
  * @param startPos - The starting position of this node in the original input string.
138
158
  * @returns The newly created node with childNodes, type, value, and isClosed properties.
139
- * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH.
159
+ * @throws {Error} If the nesting depth exceeds MAX_NODE_DEPTH outside a FETCH data item.
140
160
  */
141
161
  createNode(parentNode, startPos) {
142
162
  let node = {
@@ -154,8 +174,20 @@ export class TokenParser {
154
174
  node.depth = 0;
155
175
  }
156
176
  if (node.depth > MAX_NODE_DEPTH) {
157
- // A bounded prefix only, the error travels whole into logs and rejections
158
- throw createImapError('Too much nesting in IMAP string', 'MAX_IMAP_NESTING_REACHED', { _imapStr: this.str.slice(0, ERROR_CONTEXT_LENGTH) });
177
+ if (!this.isFetchResponse()) {
178
+ // A bounded prefix only, the error travels whole into logs and rejections
179
+ throw createImapError('Too much nesting in IMAP string', 'MAX_IMAP_NESTING_REACHED', { _imapStr: this.str.slice(0, ERROR_CONTEXT_LENGTH) });
180
+ }
181
+ // A FETCH row carries the data of one message, and the item nesting this deep (a
182
+ // BODYSTRUCTURE of a message with a hundred nested multiparts, which the sender of
183
+ // that message controls) is one of several. Failing the line would drop the whole
184
+ // row, and with it the message, from the FETCH result. The data item is marked
185
+ // instead, which getAttributes() turns into NIL, and the tokenizer carries on: it
186
+ // keeps no stack, so the depth costs it nothing. Marked on the first crossing only,
187
+ // every node below that one is already inside a marked item.
188
+ if (parentNode.depth === MAX_NODE_DEPTH) {
189
+ this.truncateFetchDataItem(parentNode);
190
+ }
159
191
  }
160
192
  if (typeof startPos === 'number') {
161
193
  node.startPos = startPos;
@@ -165,6 +197,44 @@ export class TokenParser {
165
197
  }
166
198
  return node;
167
199
  }
200
+ /**
201
+ * Whether the line being parsed is a message data response (`* 12 FETCH (...)`): a numeric
202
+ * command with FETCH as its first attribute. Only those get the per-item nesting fallback.
203
+ *
204
+ * @returns True for an untagged FETCH response
205
+ */
206
+ isFetchResponse() {
207
+ let command = this.parent.command;
208
+ let first = this.tree.childNodes[0];
209
+ return (typeof command === 'string' &&
210
+ /^\d+$/.test(command) &&
211
+ !!first &&
212
+ first.type === 'ATOM' &&
213
+ typeof first.value === 'string' &&
214
+ first.value.toUpperCase() === 'FETCH');
215
+ }
216
+ /**
217
+ * Marks the FETCH data item a too deeply nested node belongs to as truncated and records
218
+ * its key (the item before it in the list, e.g. BODYSTRUCTURE) for the caller to report.
219
+ *
220
+ * @param node - The node at MAX_NODE_DEPTH whose child would exceed the limit
221
+ */
222
+ truncateFetchDataItem(node) {
223
+ let item = node;
224
+ while (item.depth > FETCH_DATA_ITEM_DEPTH && item.parentNode) {
225
+ item = item.parentNode;
226
+ }
227
+ if (item.truncated) {
228
+ return;
229
+ }
230
+ item.truncated = true;
231
+ // The item is the last child of the list while it is being parsed, so its key is the
232
+ // child before it. A key is an atom (BODYSTRUCTURE, ENVELOPE, BODY); anything else means
233
+ // the row itself is malformed, and the key is reported as unknown.
234
+ let siblings = item.parentNode ? item.parentNode.childNodes : [];
235
+ let key = siblings[siblings.length - 2];
236
+ this.truncatedAttributes.push(key && key.type === 'ATOM' && typeof key.value === 'string' ? key.value.toUpperCase() : '?');
237
+ }
168
238
  /**
169
239
  * Processes the entire input string character by character using a state machine.
170
240
  * Transitions between states (NORMAL, ATOM, STRING, LITERAL, SEQUENCE, PARTIAL, TEXT)
@@ -39,6 +39,11 @@ export interface ImapResponse {
39
39
  attributes?: ImapAttributeList | undefined;
40
40
  /** Number of leading NUL bytes stripped from the line, if any */
41
41
  nullBytesRemoved?: number | undefined;
42
+ /**
43
+ * Keys of the FETCH data items (e.g. BODYSTRUCTURE) that were replaced by NIL because they
44
+ * nested deeper than the parser's limit, if any
45
+ */
46
+ truncatedAttributes?: string[] | undefined;
42
47
  }
43
48
  /**
44
49
  * What the command compiler accepts as a node: parsed tokens and lists, and also bare
@@ -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