imapflow 2.2.11 → 2.4.0

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 (58) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/cjs/commands/append.d.ts +1 -1
  3. package/dist/cjs/commands/append.js +15 -4
  4. package/dist/cjs/commands/compress.js +2 -2
  5. package/dist/cjs/commands/idle.js +9 -9
  6. package/dist/cjs/commands/status.js +2 -4
  7. package/dist/cjs/connection-deadline.d.ts +1 -1
  8. package/dist/cjs/connection-deadline.js +2 -4
  9. package/dist/cjs/download.js +42 -26
  10. package/dist/cjs/errors.d.ts +15 -0
  11. package/dist/cjs/errors.js +22 -0
  12. package/dist/cjs/handler/imap-compiler.d.ts +4 -4
  13. package/dist/cjs/handler/imap-compiler.js +18 -18
  14. package/dist/cjs/handler/imap-parser.js +28 -3
  15. package/dist/cjs/handler/imap-stream.js +9 -8
  16. package/dist/cjs/handler/limits.d.ts +1 -1
  17. package/dist/cjs/handler/limits.js +5 -7
  18. package/dist/cjs/handler/parser-instance.js +23 -38
  19. package/dist/cjs/handler/token-parser.d.ts +12 -0
  20. package/dist/cjs/handler/token-parser.js +44 -104
  21. package/dist/cjs/imap-flow.d.ts +4 -3
  22. package/dist/cjs/imap-flow.js +98 -72
  23. package/dist/cjs/package-info.d.ts +1 -1
  24. package/dist/cjs/package-info.js +1 -1
  25. package/dist/cjs/proxy-connection.js +2 -5
  26. package/dist/cjs/search-compiler.js +2 -3
  27. package/dist/cjs/tools.d.ts +6 -3
  28. package/dist/cjs/tools.js +13 -13
  29. package/dist/cjs/types.d.ts +11 -0
  30. package/dist/esm/commands/append.d.ts +1 -1
  31. package/dist/esm/commands/append.js +15 -4
  32. package/dist/esm/commands/compress.js +2 -2
  33. package/dist/esm/commands/idle.js +9 -9
  34. package/dist/esm/commands/status.js +2 -4
  35. package/dist/esm/connection-deadline.d.ts +1 -1
  36. package/dist/esm/connection-deadline.js +2 -4
  37. package/dist/esm/download.js +42 -26
  38. package/dist/esm/errors.d.ts +15 -0
  39. package/dist/esm/errors.js +21 -0
  40. package/dist/esm/handler/imap-compiler.d.ts +4 -4
  41. package/dist/esm/handler/imap-compiler.js +18 -18
  42. package/dist/esm/handler/imap-parser.js +28 -3
  43. package/dist/esm/handler/imap-stream.js +9 -8
  44. package/dist/esm/handler/limits.d.ts +1 -1
  45. package/dist/esm/handler/limits.js +5 -7
  46. package/dist/esm/handler/parser-instance.js +23 -38
  47. package/dist/esm/handler/token-parser.d.ts +12 -0
  48. package/dist/esm/handler/token-parser.js +44 -104
  49. package/dist/esm/imap-flow.d.ts +4 -3
  50. package/dist/esm/imap-flow.js +99 -73
  51. package/dist/esm/package-info.d.ts +1 -1
  52. package/dist/esm/package-info.js +1 -1
  53. package/dist/esm/proxy-connection.js +2 -5
  54. package/dist/esm/search-compiler.js +2 -3
  55. package/dist/esm/tools.d.ts +6 -3
  56. package/dist/esm/tools.js +14 -14
  57. package/dist/esm/types.d.ts +11 -0
  58. package/package.json +6 -3
@@ -1,4 +1,5 @@
1
1
  import { guardedPromise, hasCapability, isServerRefusal, logConnectionError, restampConnectionError, unrefTimer, clearTimer, getSelectedMailbox } from '../tools.js';
2
+ import { createImapError } from '../errors.js';
2
3
  const NOOP_INTERVAL = 2 * 60 * 1000;
3
4
  /**
4
5
  * Marks the connection as idling on behalf of one session and returns a release function.
@@ -124,14 +125,15 @@ async function runIdle(connection) {
124
125
  }
125
126
  catch (err) {
126
127
  logConnectionError(connection, 'IDLE session failed', err);
127
- // A tagged NO or BAD only means the server refused IDLE; the connection is still usable,
128
- // so the waiters are released by the finally block below and their own commands run.
129
- // Anything else (close, lost socket, parser failure) fails the waiters too.
130
- let refusedByServer = isServerRefusal(err);
131
- if (refusedByServer) {
128
+ // A tagged NO or BAD only means the server did not run IDLE; the connection is still
129
+ // usable, so the waiters are released by the finally block below and their own commands
130
+ // run. Anything else (close, lost socket, parser failure) fails the waiters too. Only a
131
+ // refusal turns IDLE off for the session, a throttled IDLE is tried again later.
132
+ let answeredByServer = err.responseStatus === 'NO' || err.responseStatus === 'BAD';
133
+ if (isServerRefusal(err)) {
132
134
  connection.skipIdle = true;
133
135
  }
134
- if (preCheckWaitQueue.length && !refusedByServer) {
136
+ if (preCheckWaitQueue.length && !answeredByServer) {
135
137
  // One error for the whole queue: every waiter failed at the same site, for the same
136
138
  // reason. Built inside the guard so a teardown with nothing queued - the common case -
137
139
  // does not pay for an Error and its stack capture.
@@ -188,9 +190,7 @@ async function pollOnce(connection, session) {
188
190
  highestModseq: true
189
191
  });
190
192
  if (!status) {
191
- let err = new Error('STATUS poll failed');
192
- err.code = 'PollFailed';
193
- throw err;
193
+ throw createImapError('STATUS poll failed', 'PollFailed');
194
194
  }
195
195
  break;
196
196
  }
@@ -1,5 +1,6 @@
1
1
  import { encodePath, normalizePath, buildStatusQueryAttributes, isRev2Active, isAuthenticatedState, emitSafe, getSelectedMailbox, logConnectionError } from '../tools.js';
2
2
  import { parseStatusList } from './status-fields.js';
3
+ import { createImapError } from '../errors.js';
3
4
  // STATUS fields that also refresh the live mailbox state when the queried mailbox is the
4
5
  // currently selected one. Keyed by the output property name parseStatusList() reports.
5
6
  const MAILBOX_UPDATERS = {
@@ -96,10 +97,7 @@ export default async function status(connection, path, query) {
96
97
  return false;
97
98
  });
98
99
  if (folders && !folders.length) {
99
- let error = new Error(`Mailbox doesn't exist: ${path}`);
100
- error.code = 'NotFound';
101
- error.response = err;
102
- throw error;
100
+ throw createImapError(`Mailbox doesn't exist: ${path}`, 'NotFound', { response: err });
103
101
  }
104
102
  }
105
103
  connection.log.warn({ err, cid: connection.id });
@@ -1,4 +1,4 @@
1
- import type { ImapFlowError } from './errors.js';
1
+ import { type ImapFlowError } from './errors.js';
2
2
  export declare const CONNECT_TIMEOUT: number;
3
3
  /**
4
4
  * One deadline for an entire connection attempt.
@@ -1,4 +1,5 @@
1
1
  import { clearTimer } from './tools.js';
2
+ import { createImapError } from './errors.js';
2
3
  // Default upper bound for establishing a usable transport, including DNS and proxy negotiation.
3
4
  export const CONNECT_TIMEOUT = 90 * 1000;
4
5
  /**
@@ -28,10 +29,7 @@ export class ConnectionDeadline {
28
29
  * @returns The shared `CONNECT_TIMEOUT` error.
29
30
  */
30
31
  error() {
31
- let err = new Error('Failed to establish connection in required time');
32
- err.code = 'CONNECT_TIMEOUT';
33
- err.details = { connectionTimeout: this.timeout };
34
- return err;
32
+ return createImapError('Failed to establish connection in required time', 'CONNECT_TIMEOUT', { details: { connectionTimeout: this.timeout } });
35
33
  }
36
34
  /**
37
35
  * Maps a dependency's own expiry onto the shared `CONNECT_TIMEOUT` shape, so callers see one
@@ -11,6 +11,7 @@ import { LimitedPassthrough, normalizeByteLimit } from './limited-passthrough.js
11
11
  // What a wait on the head stream listens for: it can take more input, it failed, or it went
12
12
  // away (a consumer destroying it closes it without a 'drain')
13
13
  const DRAIN_WAIT_EVENTS = ['drain', 'error', 'close'];
14
+ import { createImapError } from './errors.js';
14
15
  import { getDecoder, isUnsafeKey } from './tools.js';
15
16
  const isEmptySection = (value) => !value?.length;
16
17
  /**
@@ -81,10 +82,7 @@ async function fetchExpected(client, range, query, options, expected) {
81
82
  cid: client.id
82
83
  });
83
84
  if (attempt >= MAX_FETCH_ATTEMPTS) {
84
- let err = new Error('Server kept answering with data of another request');
85
- err.code = 'DownloadIncomplete';
86
- err.cid = client.id;
87
- throw err;
85
+ throw createImapError('Server kept answering with data of another request', 'DownloadIncomplete', { cid: client.id });
88
86
  }
89
87
  }
90
88
  }
@@ -157,7 +155,13 @@ export async function downloadMessage(client, range, part, options) {
157
155
  // Special handling for part "1": in single-node emails (no childNodes),
158
156
  // the body is accessed via "TEXT" rather than "1", and headers via
159
157
  // "HEADER" instead of "1.MIME". Check bodyStructure to detect this.
160
- let response = await client.fetchOne(range, { uid: true, bodyStructure: true }, downloadOptions);
158
+ let expectedUid = requestedUid(range, downloadOptions);
159
+ let response = await fetchExpected(client, range, { uid: true, bodyStructure: true }, downloadOptions, {
160
+ uid: expectedUid,
161
+ origins: new Map(),
162
+ // a message addressed by UID is taken to exist, see getNextPart() below
163
+ retryEmpty: expectedUid ? () => true : undefined
164
+ });
161
165
  if (!response) {
162
166
  return {};
163
167
  }
@@ -179,7 +183,8 @@ export async function downloadMessage(client, range, part, options) {
179
183
  let fetchAborted = false;
180
184
  // A consumer that gave up: its 'close' may still be a tick away from setting fetchAborted,
181
185
  // so the stream's own flag is checked as well
182
- let downloadAborted = () => fetchAborted || output.destroyed;
186
+ // `output` is unset until the head chunk is in, and the head chunk's retry asks already
187
+ let downloadAborted = () => fetchAborted || !!output?.destroyed;
183
188
  let getNextPart = async (query) => {
184
189
  query = query || {};
185
190
  let mimeKey;
@@ -213,14 +218,23 @@ export async function downloadMessage(client, range, part, options) {
213
218
  };
214
219
  query.bodyParts.push(contentRequest);
215
220
  }
221
+ let expectedUid = uid || requestedUid(range, downloadOptions);
216
222
  let expected = {
217
- uid: uid || requestedUid(range, downloadOptions),
223
+ uid: expectedUid,
218
224
  origins: new Map([[part || '', processed]]),
219
- // every chunk after the first is of a message that was there a moment ago
220
- retryEmpty: processed > 0 ? () => !downloadAborted() : undefined
225
+ // Every chunk after the first is of a message that was there a moment ago, and a
226
+ // message the caller addresses by UID is taken to exist: an empty answer may be a
227
+ // late one (Apache James), a message that is really gone costs two more requests
228
+ retryEmpty: processed > 0 || expectedUid ? () => !downloadAborted() : undefined
221
229
  };
222
230
  let response = await fetchExpected(client, range, query, downloadOptions, expected);
223
231
  if (!response) {
232
+ // An empty answer also comes from a connection that closed while the consumer was
233
+ // paused between chunks (fetchOne() finds no mailbox then), and that is not a
234
+ // missing message: a caller that retries would skip it
235
+ if (!client.usable) {
236
+ throw client.createNoConnectionError(false, { rejectedFrom: 'download' });
237
+ }
224
238
  return { response: false, chunk: false };
225
239
  }
226
240
  if (!uid && response.uid) {
@@ -357,13 +371,19 @@ export async function downloadMessage(client, range, part, options) {
357
371
  // would still pull the entire part off the wire.
358
372
  let limiters = [];
359
373
  let isLimited = () => limiters.some(entry => entry.limited);
374
+ // Every stage after the head, so a download that ends early can release all of them
375
+ let stages = [];
360
376
  // Appending a stage means forwarding the current tail's errors to it before piping, so a
361
- // failure anywhere reaches the stream the caller is reading
377
+ // failure anywhere reaches the stream the caller is reading. The stage is destroyed with
378
+ // the error rather than only emitting it: pipe() does not end its destination on a source
379
+ // error, so a consumer that only listens for 'finish' or 'close' (a plain .pipe() into a
380
+ // file) would otherwise wait forever.
362
381
  let pipeStage = (stage) => {
363
382
  output.on('error', err => {
364
- stage.emit('error', err);
383
+ stage.destroy(err);
365
384
  });
366
385
  output = output.pipe(stage);
386
+ stages.push(stage);
367
387
  return stage;
368
388
  };
369
389
  let isTextNode = ['text/html', 'text/plain', 'text/x-amp-html'].includes(meta.contentType) || (part === '1' && !meta.contentType);
@@ -408,8 +428,8 @@ export async function downloadMessage(client, range, part, options) {
408
428
  // Cleanup function
409
429
  const cleanup = () => {
410
430
  fetchAborted = true;
411
- if (stream && !stream.destroyed) {
412
- stream.destroy();
431
+ for (let entry of [stream, ...stages]) {
432
+ entry.destroy();
413
433
  }
414
434
  };
415
435
  // Listen for stream destruction
@@ -482,11 +502,7 @@ export async function downloadMessage(client, range, part, options) {
482
502
  // Loud on purpose. Everything written downstream by this point holds
483
503
  // duplicated content, and a quiet stop is indistinguishable from a clean EOF,
484
504
  // so the consumer would store a corrupt body believing it intact.
485
- let err = new Error('Download exceeded the expected message size');
486
- err.code = 'DownloadOverflow';
487
- err.maxSize = maxTotalBytes;
488
- err.cid = client.id;
489
- throw err;
505
+ throw createImapError('Download exceeded the expected message size', 'DownloadOverflow', { maxSize: maxTotalBytes, cid: client.id });
490
506
  }
491
507
  let { response, chunk } = await getNextPart();
492
508
  // A consumer that gave up while the chunk was in flight
@@ -497,10 +513,7 @@ export async function downloadMessage(client, range, part, options) {
497
513
  // The message is gone mid-download (expunged by another client, or the
498
514
  // mailbox was closed). Ending the stream here would pass the truncated body
499
515
  // off as complete, so the consumer is told the same way as for an overflow.
500
- let err = new Error('Message disappeared before the download completed');
501
- err.code = 'DownloadIncomplete';
502
- err.cid = client.id;
503
- throw err;
516
+ throw createImapError('Message disappeared before the download completed', 'DownloadIncomplete', { cid: client.id });
504
517
  }
505
518
  if (!chunk) {
506
519
  break;
@@ -530,7 +543,7 @@ export async function downloadMessage(client, range, part, options) {
530
543
  fetchAllParts(head)
531
544
  .catch(err => {
532
545
  if (!fetchAborted && stream && !stream.destroyed) {
533
- stream.emit('error', err);
546
+ stream.destroy(err);
534
547
  /* c8 ignore start */ // the else logs when a fetch error arrives after the stream was already torn down (timing-dependent)
535
548
  }
536
549
  else {
@@ -555,7 +568,7 @@ export async function downloadMessage(client, range, part, options) {
555
568
  // above rejects a promise nobody holds and takes the process down on
556
569
  // unhandledRejection. Reaching it always means an invariant broke - the head
557
570
  // stream kept pipeStage()'s error forwarder for the life of the download, so
558
- // emit('error') above has somewhere to go - which is why it logs at error even
571
+ // destroy(err) above has somewhere to go - which is why it logs at error even
559
572
  // for a routine-looking connection code.
560
573
  .catch(err => client.log.error({ msg: 'Failed to fail the download stream', err, cid: client.id }));
561
574
  };
@@ -594,9 +607,12 @@ export async function downloadMessageParts(client, range, parts, options) {
594
607
  contentRequests.set(part, contentRequest);
595
608
  query.bodyParts.push(contentRequest);
596
609
  }
610
+ let expectedUid = requestedUid(range, downloadOptions);
597
611
  let response = await fetchExpected(client, range, query, downloadOptions, {
598
- uid: requestedUid(range, downloadOptions),
599
- origins: partialStarts(query.bodyParts)
612
+ uid: expectedUid,
613
+ origins: partialStarts(query.bodyParts),
614
+ // a message addressed by UID is taken to exist, as for download()
615
+ retryEmpty: expectedUid ? () => true : undefined
600
616
  });
601
617
  if (!response || !response.bodyParts) {
602
618
  return {};
@@ -29,6 +29,7 @@ export declare const ImapFlowErrorCode: {
29
29
  readonly MissingServerExtension: "MissingServerExtension";
30
30
  readonly ParserError: "ParserError";
31
31
  readonly ParserErrorExchange: "ParserErrorExchange";
32
+ readonly ParserErrorInternal: "ParserErrorInternal";
32
33
  readonly MAX_IMAP_NESTING_REACHED: "MAX_IMAP_NESTING_REACHED";
33
34
  readonly LineTooLarge: "LineTooLarge";
34
35
  readonly LiteralTooLarge: "LiteralTooLarge";
@@ -38,6 +39,7 @@ export declare const ImapFlowErrorCode: {
38
39
  readonly InvalidTextValue: "InvalidTextValue";
39
40
  readonly InvalidSequenceSet: "InvalidSequenceSet";
40
41
  readonly InvalidSearchQuery: "InvalidSearchQuery";
42
+ readonly InvalidMessageContent: "InvalidMessageContent";
41
43
  readonly DownloadOverflow: "DownloadOverflow";
42
44
  readonly DownloadIncomplete: "DownloadIncomplete";
43
45
  readonly ProxyError: "ProxyError";
@@ -48,6 +50,8 @@ export declare const ImapFlowErrorCode: {
48
50
  };
49
51
  /** One of the {@link ImapFlowErrorCode} values */
50
52
  export type ImapFlowErrorCode = (typeof ImapFlowErrorCode)[keyof typeof ImapFlowErrorCode];
53
+ /** A numbered parser error code, `ParserError1` and up */
54
+ export type ParserErrorCode = `ParserError${number}`;
51
55
  /**
52
56
  * An Error raised by ImapFlow, with the extra properties the library attaches to describe
53
57
  * the failure. Every property is optional: which ones are present depends on where the
@@ -132,3 +136,14 @@ export declare class AuthenticationFailure extends Error implements ImapFlowErro
132
136
  response?: string | undefined;
133
137
  oauthError?: any;
134
138
  }
139
+ /**
140
+ * Creates an {@link ImapFlowError} with a code and optional extra properties. The code is
141
+ * checked by the compiler against {@link ImapFlowErrorCode}, so a typo or an undeclared
142
+ * code fails the build instead of reaching consumers.
143
+ *
144
+ * @param message - The error message
145
+ * @param code - The error code
146
+ * @param props - Extra properties to set on the error
147
+ * @returns The error, ready to throw or reject with
148
+ */
149
+ export declare function createImapError(message: string, code: ImapFlowErrorCode | ParserErrorCode, props?: Omit<ImapFlowError, 'name' | 'message' | 'stack' | 'code'> | undefined): ImapFlowError;
@@ -32,6 +32,8 @@ export const ImapFlowErrorCode = {
32
32
  // response parsing and size limits
33
33
  ParserError: 'ParserError',
34
34
  ParserErrorExchange: 'ParserErrorExchange',
35
+ // an unexpected exception inside the parser, re-raised with a code; a parser bug
36
+ ParserErrorInternal: 'ParserErrorInternal',
35
37
  MAX_IMAP_NESTING_REACHED: 'MAX_IMAP_NESTING_REACHED',
36
38
  LineTooLarge: 'LineTooLarge',
37
39
  LiteralTooLarge: 'LiteralTooLarge',
@@ -42,6 +44,7 @@ export const ImapFlowErrorCode = {
42
44
  InvalidTextValue: 'InvalidTextValue',
43
45
  InvalidSequenceSet: 'InvalidSequenceSet',
44
46
  InvalidSearchQuery: 'InvalidSearchQuery',
47
+ InvalidMessageContent: 'InvalidMessageContent',
45
48
  // download()
46
49
  DownloadOverflow: 'DownloadOverflow',
47
50
  DownloadIncomplete: 'DownloadIncomplete',
@@ -62,3 +65,21 @@ export class AuthenticationFailure extends Error {
62
65
  this.authenticationFailed = true;
63
66
  }
64
67
  }
68
+ /**
69
+ * Creates an {@link ImapFlowError} with a code and optional extra properties. The code is
70
+ * checked by the compiler against {@link ImapFlowErrorCode}, so a typo or an undeclared
71
+ * code fails the build instead of reaching consumers.
72
+ *
73
+ * @param message - The error message
74
+ * @param code - The error code
75
+ * @param props - Extra properties to set on the error
76
+ * @returns The error, ready to throw or reject with
77
+ */
78
+ export function createImapError(message, code, props) {
79
+ const error = new Error(message);
80
+ error.code = code;
81
+ if (props) {
82
+ Object.assign(error, props);
83
+ }
84
+ return error;
85
+ }
@@ -12,13 +12,13 @@ import type { CompilerOptions, ImapCompileInput } from './types.js';
12
12
  * @param options.isLogging - If true, redacts sensitive values and truncates long strings/literals for logging purposes.
13
13
  * @param options.literalPlus - If true, uses the LITERAL+ extension (appends "+" to literal length markers).
14
14
  * @param options.literalMinus - If true, uses the LITERAL- extension for literals up to 4096 bytes.
15
- * @returns A promise that resolves to an array of Buffers (if asArray is true) or a single concatenated Buffer.
15
+ * @returns An array of Buffers (if asArray is true) or a single concatenated Buffer.
16
16
  */
17
17
  declare function compiler(response: ImapCompileInput, options: CompilerOptions & {
18
18
  asArray: true;
19
- }): Promise<Buffer[]>;
19
+ }): Buffer[];
20
20
  declare function compiler(response: ImapCompileInput, options?: (CompilerOptions & {
21
21
  asArray?: false | undefined;
22
- }) | undefined): Promise<Buffer>;
23
- declare function compiler(response: ImapCompileInput, options?: CompilerOptions | undefined): Promise<Buffer | Buffer[]>;
22
+ }) | undefined): Buffer;
23
+ declare function compiler(response: ImapCompileInput, options?: CompilerOptions | undefined): Buffer | Buffer[];
24
24
  export default compiler;
@@ -1,5 +1,6 @@
1
1
  /* eslint no-console: 0, new-cap: 0 */
2
2
  import imapFormalSyntax from './imap-formal-syntax.js';
3
+ import { createImapError } from '../errors.js';
3
4
  // A single element of a sequence-set as defined by the RFC 9051 grammar: a number
4
5
  // or a range, where "*" stands for the largest number in use. Digit strings are not
5
6
  // range-checked here (a server rejects "0" or an overlong number on its own); the
@@ -41,13 +42,11 @@ const CRLF = /[\r\n]/;
41
42
  */
42
43
  const quoteString = (value) => {
43
44
  if (NOT_QUOTABLE.test(value)) {
44
- let error = new Error('Unquotable character in IMAP string value');
45
- error.code = 'InvalidStringValue';
46
- throw error;
45
+ throw createImapError('Unquotable character in IMAP string value', 'InvalidStringValue');
47
46
  }
48
47
  return '"' + value.replace(/["\\]/g, char => '\\' + char) + '"';
49
48
  };
50
- async function compiler(response, options) {
49
+ function compiler(response, options) {
51
50
  let { asArray, isLogging, literalPlus, literalMinus } = options || {};
52
51
  const respParts = [];
53
52
  // Formats an entry (string, number or Buffer) into the Buffer that is written to
@@ -62,9 +61,7 @@ async function compiler(response, options) {
62
61
  const emitEntry = (entry, opts) => {
63
62
  let { returnEmpty, raw } = opts || {};
64
63
  if (!raw && !isLogging && (typeof entry === 'string' || Buffer.isBuffer(entry)) && CRLF.test(entry.toString('latin1'))) {
65
- let error = new Error('Line terminator in IMAP token');
66
- error.code = 'InvalidTokenValue';
67
- throw error;
64
+ throw createImapError('Line terminator in IMAP token', 'InvalidTokenValue');
68
65
  }
69
66
  if (typeof entry === 'string') {
70
67
  return Buffer.from(entry);
@@ -88,7 +85,7 @@ async function compiler(response, options) {
88
85
  // Set right after the compiler writes "(" or "[" itself, so the first element inside gets no
89
86
  // leading space
90
87
  let afterOpener = false;
91
- let walk = async (node, options) => {
88
+ let walk = (node, options) => {
92
89
  options = options || {};
93
90
  // Add a space separator when:
94
91
  // - The previous token was a LITERAL. Literal data ends exactly at its declared length, so
@@ -120,7 +117,7 @@ async function compiler(response, options) {
120
117
  if (subArray && !Array.isArray(child)) {
121
118
  subArray = false;
122
119
  }
123
- await walk(child, { subArray });
120
+ walk(child, { subArray });
124
121
  }
125
122
  resp.push(emitEntry(')'));
126
123
  afterOpener = false;
@@ -154,6 +151,11 @@ async function compiler(response, options) {
154
151
  resp.push(emitEntry(logPlaceholder(node.value.length, 'literal')));
155
152
  }
156
153
  else {
154
+ // The size marker must match the bytes emitEntry() writes, and it writes
155
+ // nothing for a value that is not a Buffer or a string
156
+ if (node.value && !Buffer.isBuffer(node.value) && typeof node.value !== 'string') {
157
+ throw createImapError('Literal value must be a Buffer or a string', 'InvalidTokenValue');
158
+ }
157
159
  // The literal size marker counts octets - string values are written as
158
160
  // UTF-8, so their UTF-16 .length would undercount multi-byte characters
159
161
  let literalLength = !node.value ? 0 : Buffer.isBuffer(node.value) ? node.value.length : Buffer.byteLength(node.value.toString());
@@ -208,9 +210,7 @@ async function compiler(response, options) {
208
210
  if (!isLogging) {
209
211
  val = node.value === null || node.value === undefined ? '' : node.value.toString();
210
212
  if (!isValidSequenceSet(val)) {
211
- let error = new Error('Invalid sequence set value');
212
- error.code = 'InvalidSequenceSet';
213
- throw error;
213
+ throw createImapError('Invalid sequence set value', 'InvalidSequenceSet');
214
214
  }
215
215
  resp.push(emitEntry(val, { raw: true }));
216
216
  }
@@ -225,9 +225,7 @@ async function compiler(response, options) {
225
225
  // this check runs first only to raise the more specific InvalidTextValue code.
226
226
  if (node.value) {
227
227
  if (!isLogging && CRLF.test(node.value.toString())) {
228
- let error = new Error('Line terminator in IMAP text value');
229
- error.code = 'InvalidTextValue';
230
- throw error;
228
+ throw createImapError('Line terminator in IMAP text value', 'InvalidTextValue');
231
229
  }
232
230
  resp.push(emitEntry(node.value));
233
231
  }
@@ -262,7 +260,7 @@ async function compiler(response, options) {
262
260
  resp.push(emitEntry('['));
263
261
  afterOpener = true;
264
262
  for (let child of node.section) {
265
- await walk(child);
263
+ walk(child);
266
264
  }
267
265
  resp.push(emitEntry(']'));
268
266
  afterOpener = false;
@@ -281,13 +279,15 @@ async function compiler(response, options) {
281
279
  if (response.attributes) {
282
280
  let attributes = Array.isArray(response.attributes) ? response.attributes : [].concat(response.attributes);
283
281
  for (let child of attributes) {
284
- await walk(child);
282
+ walk(child);
285
283
  }
286
284
  }
287
285
  if (resp.length) {
288
286
  respParts.push(resp);
289
287
  }
290
- const compiled = respParts.map(part => Buffer.concat(part));
288
+ // A part with a single entry (the data of a synchronizing literal) is used as is: copying a
289
+ // large message only to send it doubles its memory
290
+ const compiled = respParts.map(part => (part.length === 1 ? part[0] : Buffer.concat(part)));
291
291
  // without asArray there is a single part, returned as is instead of copied
292
292
  return asArray ? compiled : compiled.length === 1 ? compiled[0] : Buffer.concat(compiled);
293
293
  }
@@ -1,5 +1,30 @@
1
1
  import imapFormalSyntax from './imap-formal-syntax.js';
2
2
  import { ParserInstance } from './parser-instance.js';
3
+ import { createImapError } from '../errors.js';
4
+ import { boundedInput } from './limits.js';
5
+ // The codes the parser raises on purpose: the numbered ParserErrorN codes, ParserErrorExchange,
6
+ // the nesting limit and the inline literal size limit
7
+ const PARSER_ERROR_CODES = /^(?:ParserError\d+|ParserErrorExchange|MAX_IMAP_NESTING_REACHED|LiteralTooLarge)$/;
8
+ /**
9
+ * Anything the parser throws other than one of its own coded errors - a TypeError from a parser
10
+ * bug, a RangeError from runaway recursion or a bad Buffer offset (which carries a Node code), a
11
+ * thrown non-Error - is re-raised as a coded `ParserErrorInternal`, so callers that match on
12
+ * codes still handle it and settle the command the line belonged to. The fuzz suite treats this
13
+ * code as a failure, so the guard does not hide parser bugs from the tests.
14
+ *
15
+ * @param err - The thrown value
16
+ * @param input - The line that was being parsed
17
+ * @returns The error to rethrow
18
+ */
19
+ function asParserError(err, input) {
20
+ if (err instanceof Error && PARSER_ERROR_CODES.test(err.code || '')) {
21
+ return err;
22
+ }
23
+ return createImapError(`Unexpected parser failure: ${err instanceof Error ? err.message : String(err)}`, 'ParserErrorInternal', {
24
+ parserContext: boundedInput(input.toString()),
25
+ ...(err instanceof Error ? { _err: err } : {})
26
+ });
27
+ }
3
28
  /**
4
29
  * Parses a raw IMAP command or response buffer into a structured object.
5
30
  * Handles edge cases such as null-byte-padded responses from buggy servers and
@@ -20,7 +45,7 @@ export default async function parser(command, options) {
20
45
  // Workaround for buggy IMAP servers that pad responses with leading NUL (\x00) bytes.
21
46
  // Some servers (observed in the wild) prepend null bytes to their output, which would
22
47
  // cause parsing to fail. We strip them and note how many were removed for diagnostics.
23
- if (command[0] === 0) {
48
+ if (Buffer.isBuffer(command) && command[0] === 0) {
24
49
  // find the first non null byte and trim
25
50
  let firstNonNull = -1;
26
51
  for (let i = 0; i < command.length; i++) {
@@ -33,7 +58,7 @@ export default async function parser(command, options) {
33
58
  // All bytes are null, treat as a BAD response
34
59
  return { tag: '*', command: 'BAD', attributes: [] };
35
60
  }
36
- command = command.slice(firstNonNull);
61
+ command = command.subarray(firstNonNull);
37
62
  nullBytesRemoved = firstNonNull;
38
63
  }
39
64
  const parserInstance = new ParserInstance(command, options);
@@ -65,7 +90,7 @@ export default async function parser(command, options) {
65
90
  }
66
91
  }
67
92
  catch (err) {
68
- let error = err;
93
+ let error = asParserError(err, command);
69
94
  if (error.code === 'ParserErrorExchange' && error.parserContext && error.parserContext.value) {
70
95
  return error.parserContext.value;
71
96
  }
@@ -1,6 +1,7 @@
1
1
  import { Transform } from 'node:stream';
2
2
  import { createConnectionLogger } from '../logger.js';
3
3
  import { MAX_LITERAL_SIZE, MAX_LINE_SIZE, MAX_RESPONSE_SIZE, normalizeLimit, createLiteralTooLargeError } from './limits.js';
4
+ import { createImapError } from '../errors.js';
4
5
  const LINE = 0x01;
5
6
  const LITERAL = 0x02;
6
7
  const LF = 0x0a;
@@ -177,10 +178,10 @@ export class ImapStream extends Transform {
177
178
  if (lineLength <= this.maxLineLength) {
178
179
  return true;
179
180
  }
180
- const err = new Error(`Line length ${lineLength} exceeds maximum allowed size of ${this.maxLineLength} bytes`);
181
- err.code = 'LineTooLarge';
182
- err.lineLength = lineLength;
183
- err.maxSize = this.maxLineLength;
181
+ const err = createImapError(`Line length ${lineLength} exceeds maximum allowed size of ${this.maxLineLength} bytes`, 'LineTooLarge', {
182
+ lineLength,
183
+ maxSize: this.maxLineLength
184
+ });
184
185
  return this.failStream(err);
185
186
  }
186
187
  /**
@@ -203,10 +204,10 @@ export class ImapStream extends Transform {
203
204
  }
204
205
  return true;
205
206
  }
206
- const err = new Error(`Response size ${total} exceeds maximum allowed size of ${this.maxResponseSize} bytes`);
207
- err.code = 'ResponseTooLarge';
208
- err.responseSize = total;
209
- err.maxSize = this.maxResponseSize;
207
+ const err = createImapError(`Response size ${total} exceeds maximum allowed size of ${this.maxResponseSize} bytes`, 'ResponseTooLarge', {
208
+ responseSize: total,
209
+ maxSize: this.maxResponseSize
210
+ });
210
211
  return this.failStream(err);
211
212
  }
212
213
  /**
@@ -1,4 +1,4 @@
1
- import type { ImapFlowError } from '../errors.js';
1
+ import { type ImapFlowError } from '../errors.js';
2
2
  export declare const MAX_LITERAL_SIZE: number;
3
3
  export declare const MAX_LINE_SIZE: number;
4
4
  export declare const MAX_RESPONSE_SIZE: number;
@@ -1,6 +1,7 @@
1
1
  // Shared response-size limits for the IMAP parser. Kept in one place so the streaming parser
2
2
  // (ImapStream) and the standalone token parser cannot drift apart, and so the documented
3
3
  // defaults in the ImapFlowOptions type describe both paths.
4
+ import { createImapError } from '../errors.js';
4
5
  // Maximum allowed literal size: 1GB (1073741824 bytes)
5
6
  export const MAX_LITERAL_SIZE = 1024 * 1024 * 1024;
6
7
  // Default maximum length of a single line (a response without a literal). Matches the literal cap:
@@ -51,10 +52,7 @@ export const normalizeLimit = (value, defaultValue) => (Number.isInteger(value)
51
52
  * @param reason - What the bound was, when it is not the configured maximum.
52
53
  * @returns The error to emit or throw.
53
54
  */
54
- export const createLiteralTooLargeError = (literalSize, maxSize, reason) => {
55
- const err = new Error(`Literal size ${literalSize} exceeds ${reason || `maximum allowed size of ${maxSize} bytes`}`);
56
- err.code = 'LiteralTooLarge';
57
- err.literalSize = literalSize;
58
- err.maxSize = maxSize;
59
- return err;
60
- };
55
+ export const createLiteralTooLargeError = (literalSize, maxSize, reason) => createImapError(`Literal size ${literalSize} exceeds ${reason || `maximum allowed size of ${maxSize} bytes`}`, 'LiteralTooLarge', {
56
+ literalSize,
57
+ maxSize
58
+ });