imapflow 2.2.5 → 2.2.7

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 (74) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cjs/commands/append.js +10 -3
  3. package/dist/cjs/commands/authenticate.js +27 -18
  4. package/dist/cjs/commands/close.d.ts +12 -1
  5. package/dist/cjs/commands/close.js +4 -2
  6. package/dist/cjs/commands/copyuid-parser.js +16 -2
  7. package/dist/cjs/commands/delete.js +2 -1
  8. package/dist/cjs/commands/esearch-parser.js +8 -2
  9. package/dist/cjs/commands/fetch.js +35 -9
  10. package/dist/cjs/commands/id.js +8 -1
  11. package/dist/cjs/commands/idle.js +15 -5
  12. package/dist/cjs/commands/list.js +10 -1
  13. package/dist/cjs/commands/login.js +5 -1
  14. package/dist/cjs/commands/logout.js +7 -0
  15. package/dist/cjs/commands/namespace.js +7 -3
  16. package/dist/cjs/commands/quota.js +3 -1
  17. package/dist/cjs/commands/rename.js +2 -1
  18. package/dist/cjs/commands/select.js +5 -0
  19. package/dist/cjs/commands/status.js +6 -1
  20. package/dist/cjs/commands/store.d.ts +1 -1
  21. package/dist/cjs/commands/store.js +1 -2
  22. package/dist/cjs/download.js +82 -91
  23. package/dist/cjs/handler/imap-compiler.js +45 -29
  24. package/dist/cjs/handler/limits.d.ts +11 -0
  25. package/dist/cjs/handler/limits.js +16 -1
  26. package/dist/cjs/handler/parser-instance.d.ts +10 -0
  27. package/dist/cjs/handler/parser-instance.js +25 -10
  28. package/dist/cjs/handler/token-parser.js +162 -78
  29. package/dist/cjs/imap-flow.js +235 -89
  30. package/dist/cjs/package-info.d.ts +1 -1
  31. package/dist/cjs/package-info.js +1 -1
  32. package/dist/cjs/proxy-connection.js +7 -7
  33. package/dist/cjs/search-compiler.js +37 -16
  34. package/dist/cjs/special-use.js +10 -5
  35. package/dist/cjs/tools.d.ts +10 -1
  36. package/dist/cjs/tools.js +30 -3
  37. package/dist/cjs/types.d.ts +19 -4
  38. package/dist/esm/commands/append.js +11 -4
  39. package/dist/esm/commands/authenticate.js +28 -19
  40. package/dist/esm/commands/close.d.ts +12 -1
  41. package/dist/esm/commands/close.js +5 -3
  42. package/dist/esm/commands/copyuid-parser.js +16 -2
  43. package/dist/esm/commands/delete.js +2 -1
  44. package/dist/esm/commands/esearch-parser.js +8 -2
  45. package/dist/esm/commands/fetch.js +35 -9
  46. package/dist/esm/commands/id.js +9 -2
  47. package/dist/esm/commands/idle.js +16 -6
  48. package/dist/esm/commands/list.js +10 -1
  49. package/dist/esm/commands/login.js +6 -2
  50. package/dist/esm/commands/logout.js +7 -0
  51. package/dist/esm/commands/namespace.js +7 -3
  52. package/dist/esm/commands/quota.js +3 -1
  53. package/dist/esm/commands/rename.js +2 -1
  54. package/dist/esm/commands/select.js +5 -0
  55. package/dist/esm/commands/status.js +7 -2
  56. package/dist/esm/commands/store.d.ts +1 -1
  57. package/dist/esm/commands/store.js +1 -2
  58. package/dist/esm/download.js +82 -91
  59. package/dist/esm/handler/imap-compiler.js +45 -29
  60. package/dist/esm/handler/limits.d.ts +11 -0
  61. package/dist/esm/handler/limits.js +14 -0
  62. package/dist/esm/handler/parser-instance.d.ts +10 -0
  63. package/dist/esm/handler/parser-instance.js +25 -10
  64. package/dist/esm/handler/token-parser.js +163 -79
  65. package/dist/esm/imap-flow.js +235 -89
  66. package/dist/esm/package-info.d.ts +1 -1
  67. package/dist/esm/package-info.js +1 -1
  68. package/dist/esm/proxy-connection.js +7 -7
  69. package/dist/esm/search-compiler.js +37 -16
  70. package/dist/esm/special-use.js +10 -5
  71. package/dist/esm/tools.d.ts +10 -1
  72. package/dist/esm/tools.js +29 -3
  73. package/dist/esm/types.d.ts +19 -4
  74. package/package.json +3 -2
@@ -19,6 +19,10 @@ const safeNumber = (value) => {
19
19
  let num = Math.round(Number(value));
20
20
  return Number.isSafeInteger(num) && num >= 0 ? num : 0;
21
21
  };
22
+ // Log output stands in a placeholder for any value longer than this, so a log entry never
23
+ // carries a copy of a large token (a message body, a long unquoted server token)
24
+ const LOG_VALUE_LIMIT = 100;
25
+ const logPlaceholder = (length, kind) => `"(* ${length}B ${kind} *)"`;
22
26
  // Characters that cannot appear in an IMAP quoted string: CR and LF terminate a
23
27
  // command line, and NUL is outside the CHAR production entirely. A value carrying
24
28
  // any of them has to be sent as a literal, so quoting it is never correct.
@@ -81,28 +85,27 @@ async function compiler(response, options) {
81
85
  .concat(response.command ? emitEntry(' ' + response.command) : []);
82
86
  let val;
83
87
  let lastType;
88
+ // Set right after the compiler writes "(" or "[" itself, so the first element inside gets no
89
+ // leading space
90
+ let afterOpener = false;
84
91
  let walk = async (node, options) => {
85
92
  options = options || {};
86
- // Determine whether a space separator is needed before this node.
87
- // Inspect the last byte written to decide context.
88
- let lastRespEntry = resp.length && resp[resp.length - 1];
89
- let lastRespByte = (lastRespEntry && lastRespEntry.length && lastRespEntry[lastRespEntry.length - 1]) || '';
90
- if (typeof lastRespByte === 'number') {
91
- lastRespByte = String.fromCharCode(lastRespByte);
92
- }
93
93
  // Add a space separator when:
94
94
  // - The previous token was a LITERAL. Literal data ends exactly at its declared length, so
95
95
  // a following token always needs an explicit separator, even though the last written byte
96
96
  // is arbitrary literal content.
97
- // - Otherwise: there is something written already (resp is not empty) and the last byte is
98
- // not an opening delimiter ('(', '<' or '['), which suppresses the space.
97
+ // - Otherwise: there is something written already (resp is not empty) and the compiler did
98
+ // not just open a list or a section. This is tracked rather than read back from the last
99
+ // written byte: a token value can itself end in "(", "[" or "<" (an atom such as "X["),
100
+ // and suppressing the space after it would fuse it with the next argument.
99
101
  // A sub-array element in a consecutive-list context never gets one (no space between
100
102
  // adjacent lists).
101
- if (lastType === 'LITERAL' || (!['(', '<', '['].includes(lastRespByte) && resp.length)) {
103
+ if (lastType === 'LITERAL' || (!afterOpener && resp.length)) {
102
104
  if (!options.subArray) {
103
105
  resp.push(emitEntry(' '));
104
106
  }
105
107
  }
108
+ afterOpener = false;
106
109
  if (node && node.buffer && !Buffer.isBuffer(node)) {
107
110
  // mongodb binary
108
111
  node = node.buffer;
@@ -110,6 +113,7 @@ async function compiler(response, options) {
110
113
  if (Array.isArray(node)) {
111
114
  lastType = 'LIST';
112
115
  resp.push(emitEntry('('));
116
+ afterOpener = true;
113
117
  // check if we need to skip separator WS between two arrays
114
118
  let subArray = node.length > 1 && Array.isArray(node[0]);
115
119
  for (let child of node) {
@@ -119,6 +123,7 @@ async function compiler(response, options) {
119
123
  await walk(child, { subArray });
120
124
  }
121
125
  resp.push(emitEntry(')'));
126
+ afterOpener = false;
122
127
  return;
123
128
  }
124
129
  if (!node && typeof node !== 'string' && typeof node !== 'number' && !Buffer.isBuffer(node)) {
@@ -126,8 +131,8 @@ async function compiler(response, options) {
126
131
  return;
127
132
  }
128
133
  if (typeof node === 'string' || Buffer.isBuffer(node)) {
129
- if (isLogging && node.length > 100) {
130
- resp.push(emitEntry('"(* ' + node.length + 'B string *)"'));
134
+ if (isLogging && node.length > LOG_VALUE_LIMIT) {
135
+ resp.push(emitEntry(logPlaceholder(node.length, 'string')));
131
136
  }
132
137
  else {
133
138
  resp.push(emitEntry(isLogging ? JSON.stringify(node.toString()) : quoteString(node.toString())));
@@ -146,7 +151,7 @@ async function compiler(response, options) {
146
151
  switch (node.type.toUpperCase()) {
147
152
  case 'LITERAL':
148
153
  if (isLogging) {
149
- resp.push(emitEntry('"(* ' + node.value.length + 'B literal *)"'));
154
+ resp.push(emitEntry(logPlaceholder(node.value.length, 'literal')));
150
155
  }
151
156
  else {
152
157
  // The literal size marker counts octets - string values are written as
@@ -178,8 +183,8 @@ async function compiler(response, options) {
178
183
  }
179
184
  break;
180
185
  case 'STRING':
181
- if (isLogging && node.value.length > 100) {
182
- resp.push(emitEntry('"(* ' + node.value.length + 'B string *)"'));
186
+ if (isLogging && node.value.length > LOG_VALUE_LIMIT) {
187
+ resp.push(emitEntry(logPlaceholder(node.value.length, 'string')));
183
188
  }
184
189
  else {
185
190
  val = (node.value || '').toString();
@@ -194,19 +199,22 @@ async function compiler(response, options) {
194
199
  // when logging: the incoming token parser accepts sequence-shaped tokens
195
200
  // this strict grammar rejects (an ESEARCH set like "1:2:3", a folder
196
201
  // name like "12:30:00"), and re-compiling a server response for the log
197
- // or for error text must never throw.
198
- if (!isLogging && (typeof node.value === 'string' || typeof node.value === 'number' || Buffer.isBuffer(node.value))) {
199
- val = node.value.toString();
200
- if (val && !isValidSequenceSet(val)) {
202
+ // or for error text must never throw. An empty or missing set is refused
203
+ // too: it would put nothing on the wire and leave the next argument in its
204
+ // place.
205
+ // Emitted raw: the validated alphabet cannot contain a line terminator, and
206
+ // re-scanning a potentially multi-megabyte set in the choke point would
207
+ // double the cost of exactly the sets this branch exists for
208
+ if (!isLogging) {
209
+ val = node.value === null || node.value === undefined ? '' : node.value.toString();
210
+ if (!isValidSequenceSet(val)) {
201
211
  let error = new Error('Invalid sequence set value');
202
212
  error.code = 'InvalidSequenceSet';
203
213
  throw error;
204
214
  }
215
+ resp.push(emitEntry(val, { raw: true }));
205
216
  }
206
- if (node.value) {
207
- // raw: the validated alphabet cannot contain a line terminator, and
208
- // re-scanning a potentially multi-megabyte set in the choke point
209
- // would double the cost of exactly the sets this branch exists for
217
+ else if (node.value) {
210
218
  resp.push(emitEntry(node.value, { raw: true }));
211
219
  }
212
220
  break;
@@ -234,11 +242,16 @@ async function compiler(response, options) {
234
242
  case 'SECTION':
235
243
  val = (node.value || '').toString();
236
244
  if (!node.section || val) {
237
- // Verify the value contains only valid ATOM-CHAR characters.
238
- // Strip a leading backslash before checking (system flags like \Seen start with '\').
239
- // If any character fails verification, fall back to an IMAP quoted string
240
- // (JSON.stringify is used only for log output, where values are display-escaped).
241
- if (node.value === '' || imapFormalSyntax.verify(val.charAt(0) === '\\' ? val.slice(1) : val, imapFormalSyntax['ATOM-CHAR']()) >= 0) {
245
+ if (isLogging && val.length > LOG_VALUE_LIMIT) {
246
+ // A server can put a line's worth of bytes in one unquoted token
247
+ val = logPlaceholder(val.length, 'atom');
248
+ }
249
+ else if (node.value === '' ||
250
+ imapFormalSyntax.verify(val.charAt(0) === '\\' ? val.slice(1) : val, imapFormalSyntax['ATOM-CHAR']()) >= 0) {
251
+ // An empty value, or one with a character outside ATOM-CHAR (checked
252
+ // past a leading backslash, as system flags like \Seen carry one), goes
253
+ // out as an IMAP quoted string. JSON.stringify is used only for log
254
+ // output, where values are display-escaped.
242
255
  val = isLogging ? JSON.stringify(val) : quoteString(val);
243
256
  }
244
257
  resp.push(emitEntry(val));
@@ -247,10 +260,12 @@ async function compiler(response, options) {
247
260
  // e.g., BODY[HEADER.FIELDS (Subject)] or BODY[1.MIME]
248
261
  if (node.section) {
249
262
  resp.push(emitEntry('['));
263
+ afterOpener = true;
250
264
  for (let child of node.section) {
251
265
  await walk(child);
252
266
  }
253
267
  resp.push(emitEntry(']'));
268
+ afterOpener = false;
254
269
  }
255
270
  // Partial range: emit <origin.length> after the section brackets. Coerced
256
271
  // rather than joined as-is: this is the last token component written
@@ -273,6 +288,7 @@ async function compiler(response, options) {
273
288
  respParts.push(resp);
274
289
  }
275
290
  const compiled = respParts.map(part => Buffer.concat(part));
276
- return asArray ? compiled : compiled.flatMap(entry => entry);
291
+ // without asArray there is a single part, returned as is instead of copied
292
+ return asArray ? compiled : compiled.length === 1 ? compiled[0] : Buffer.concat(compiled);
277
293
  }
278
294
  export default compiler;
@@ -2,6 +2,17 @@ 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;
5
+ export declare const ERROR_CONTEXT_LENGTH = 1024;
6
+ /**
7
+ * The input a parse error carries: a bounded prefix with the full length.
8
+ *
9
+ * @param input - The input that failed to parse
10
+ * @returns The bounded prefix and the full length
11
+ */
12
+ export declare const boundedInput: (input: string) => {
13
+ input: string;
14
+ inputLength: number;
15
+ };
5
16
  /**
6
17
  * Normalizes a configured size limit. A non-negative integer is honored as-is (including 0, which
7
18
  * means "reject anything non-empty"), and `Infinity` disables the limit; anything else falls back
@@ -17,6 +17,20 @@ export const MAX_LINE_SIZE = MAX_LITERAL_SIZE;
17
17
  // of exactly the maximum permitted size impossible to receive. Configuring both limits calls for
18
18
  // the same headroom - set maxResponseSize above maxLiteralSize, not equal to it.
19
19
  export const MAX_RESPONSE_SIZE = 2 * MAX_LITERAL_SIZE;
20
+ // How much of the offending input a parse error carries along. The error travels whole into log
21
+ // entries (pino copies every property of a logged error) and into the rejection of the command
22
+ // the line belonged to, and the line can be as long as the configured line cap.
23
+ export const ERROR_CONTEXT_LENGTH = 1024;
24
+ /**
25
+ * The input a parse error carries: a bounded prefix with the full length.
26
+ *
27
+ * @param input - The input that failed to parse
28
+ * @returns The bounded prefix and the full length
29
+ */
30
+ export const boundedInput = (input) => ({
31
+ input: input.slice(0, ERROR_CONTEXT_LENGTH),
32
+ inputLength: input.length
33
+ });
20
34
  /**
21
35
  * Normalizes a configured size limit. A non-negative integer is honored as-is (including 0, which
22
36
  * means "reject anything non-empty"), and `Infinity` disables the limit; anything else falls back
@@ -21,6 +21,16 @@ export declare class ParserInstance {
21
21
  * @param options.literals - Pre-parsed literal values from the stream.
22
22
  */
23
23
  constructor(input?: Buffer | string | null | undefined, options?: ParserOptions | undefined);
24
+ /**
25
+ * The context a parse error carries: a bounded prefix of the input (and of the element that
26
+ * failed, when there is one) with their full lengths, and the position.
27
+ *
28
+ * @param element - The element that failed to parse
29
+ * @returns The context to attach to the error
30
+ */
31
+ errorContext(element?: string | undefined): {
32
+ [key: string]: unknown;
33
+ };
24
34
  /**
25
35
  * Extracts and returns the IMAP tag from the beginning of the response.
26
36
  * The tag is typically "*" for untagged responses, "+" for continuation requests,
@@ -1,6 +1,7 @@
1
1
  /* eslint new-cap: 0 */
2
2
  import imapFormalSyntax from './imap-formal-syntax.js';
3
3
  import { TokenParser } from './token-parser.js';
4
+ import { boundedInput } from './limits.js';
4
5
  /**
5
6
  * Parses a single IMAP response line into its structural components: tag, command,
6
7
  * and attributes. Handles status responses (OK, NO, BAD, PREAUTH, BYE) with their
@@ -21,6 +22,22 @@ export class ParserInstance {
21
22
  this.remainder = this.input;
22
23
  this.pos = 0;
23
24
  }
25
+ /**
26
+ * The context a parse error carries: a bounded prefix of the input (and of the element that
27
+ * failed, when there is one) with their full lengths, and the position.
28
+ *
29
+ * @param element - The element that failed to parse
30
+ * @returns The context to attach to the error
31
+ */
32
+ errorContext(element) {
33
+ let context = { ...boundedInput(this.input), pos: this.pos };
34
+ if (element !== undefined) {
35
+ let bounded = boundedInput(element);
36
+ context.element = bounded.input;
37
+ context.elementLength = bounded.inputLength;
38
+ }
39
+ return context;
40
+ }
24
41
  /**
25
42
  * Extracts and returns the IMAP tag from the beginning of the response.
26
43
  * The tag is typically "*" for untagged responses, "+" for continuation requests,
@@ -122,7 +139,7 @@ export class ParserInstance {
122
139
  if (/^\s/.test(this.remainder)) {
123
140
  let error = new Error(`Unexpected whitespace at position ${this.pos} [E1]`);
124
141
  error.code = 'ParserError1';
125
- error.parserContext = { input: this.input, pos: this.pos };
142
+ error.parserContext = this.errorContext();
126
143
  throw error;
127
144
  }
128
145
  if ((match = this.remainder.match(/^\s*[^\s]+(?=\s|$)/))) {
@@ -136,9 +153,7 @@ export class ParserInstance {
136
153
  let error = new Error(`Server returned an error: ${this.input}`);
137
154
  error.code = 'ParserErrorExchange';
138
155
  error.parserContext = {
139
- input: this.input,
140
- element,
141
- pos: this.pos,
156
+ ...this.errorContext(element),
142
157
  value: {
143
158
  tag: '*',
144
159
  command: 'BAD',
@@ -149,14 +164,14 @@ export class ParserInstance {
149
164
  }
150
165
  let error = new Error(`Unexpected char at position ${this.pos + errPos} [E2: ${JSON.stringify(element.charAt(errPos))}]`);
151
166
  error.code = 'ParserError2';
152
- error.parserContext = { input: this.input, element, pos: this.pos };
167
+ error.parserContext = this.errorContext(element);
153
168
  throw error;
154
169
  }
155
170
  }
156
171
  else {
157
172
  let error = new Error(`Unexpected end of input at position ${this.pos} [E3]`);
158
173
  error.code = 'ParserError3';
159
- error.parserContext = { input: this.input, pos: this.pos };
174
+ error.parserContext = this.errorContext();
160
175
  throw error;
161
176
  }
162
177
  this.pos += match[0].length;
@@ -177,13 +192,13 @@ export class ParserInstance {
177
192
  }
178
193
  let error = new Error(`Unexpected end of input at position ${this.pos} [E4]`);
179
194
  error.code = 'ParserError4';
180
- error.parserContext = { input: this.input, pos: this.pos };
195
+ error.parserContext = this.errorContext();
181
196
  throw error;
182
197
  }
183
198
  if (imapFormalSyntax.verify(this.remainder.charAt(0), imapFormalSyntax.SP()) >= 0) {
184
199
  let error = new Error(`Unexpected char at position ${this.pos} [E5: ${JSON.stringify(this.remainder.charAt(0))}]`);
185
200
  error.code = 'ParserError5';
186
- error.parserContext = { input: this.input, element: this.remainder, pos: this.pos };
201
+ error.parserContext = this.errorContext(this.remainder);
187
202
  throw error;
188
203
  }
189
204
  this.pos++;
@@ -201,13 +216,13 @@ export class ParserInstance {
201
216
  if (!this.remainder.length) {
202
217
  let error = new Error(`Unexpected end of input at position ${this.pos} [E6]`);
203
218
  error.code = 'ParserError6';
204
- error.parserContext = { input: this.input, pos: this.pos };
219
+ error.parserContext = this.errorContext();
205
220
  throw error;
206
221
  }
207
222
  if (/^\s/.test(this.remainder)) {
208
223
  let error = new Error(`Unexpected whitespace at position ${this.pos} [E7]`);
209
224
  error.code = 'ParserError7';
210
- error.parserContext = { input: this.input, element: this.remainder, pos: this.pos };
225
+ error.parserContext = this.errorContext(this.remainder);
211
226
  throw error;
212
227
  }
213
228
  const tokenParser = new TokenParser(this, this.pos, this.remainder, this.options);