imapflow 2.2.6 → 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 (72) hide show
  1. package/CHANGELOG.md +9 -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/delete.js +2 -1
  7. package/dist/cjs/commands/esearch-parser.js +8 -2
  8. package/dist/cjs/commands/fetch.js +35 -9
  9. package/dist/cjs/commands/id.js +8 -1
  10. package/dist/cjs/commands/idle.js +15 -5
  11. package/dist/cjs/commands/list.js +10 -1
  12. package/dist/cjs/commands/login.js +5 -1
  13. package/dist/cjs/commands/logout.js +7 -0
  14. package/dist/cjs/commands/namespace.js +7 -3
  15. package/dist/cjs/commands/quota.js +3 -1
  16. package/dist/cjs/commands/rename.js +2 -1
  17. package/dist/cjs/commands/select.js +5 -0
  18. package/dist/cjs/commands/status.js +6 -1
  19. package/dist/cjs/commands/store.d.ts +1 -1
  20. package/dist/cjs/commands/store.js +1 -2
  21. package/dist/cjs/download.js +82 -91
  22. package/dist/cjs/handler/imap-compiler.js +19 -10
  23. package/dist/cjs/handler/limits.d.ts +11 -0
  24. package/dist/cjs/handler/limits.js +16 -1
  25. package/dist/cjs/handler/parser-instance.d.ts +10 -0
  26. package/dist/cjs/handler/parser-instance.js +25 -10
  27. package/dist/cjs/handler/token-parser.js +36 -28
  28. package/dist/cjs/imap-flow.js +235 -89
  29. package/dist/cjs/package-info.d.ts +1 -1
  30. package/dist/cjs/package-info.js +1 -1
  31. package/dist/cjs/proxy-connection.js +7 -7
  32. package/dist/cjs/search-compiler.js +30 -13
  33. package/dist/cjs/special-use.js +10 -5
  34. package/dist/cjs/tools.d.ts +10 -1
  35. package/dist/cjs/tools.js +30 -3
  36. package/dist/cjs/types.d.ts +19 -4
  37. package/dist/esm/commands/append.js +11 -4
  38. package/dist/esm/commands/authenticate.js +28 -19
  39. package/dist/esm/commands/close.d.ts +12 -1
  40. package/dist/esm/commands/close.js +5 -3
  41. package/dist/esm/commands/delete.js +2 -1
  42. package/dist/esm/commands/esearch-parser.js +8 -2
  43. package/dist/esm/commands/fetch.js +35 -9
  44. package/dist/esm/commands/id.js +9 -2
  45. package/dist/esm/commands/idle.js +16 -6
  46. package/dist/esm/commands/list.js +10 -1
  47. package/dist/esm/commands/login.js +6 -2
  48. package/dist/esm/commands/logout.js +7 -0
  49. package/dist/esm/commands/namespace.js +7 -3
  50. package/dist/esm/commands/quota.js +3 -1
  51. package/dist/esm/commands/rename.js +2 -1
  52. package/dist/esm/commands/select.js +5 -0
  53. package/dist/esm/commands/status.js +7 -2
  54. package/dist/esm/commands/store.d.ts +1 -1
  55. package/dist/esm/commands/store.js +1 -2
  56. package/dist/esm/download.js +82 -91
  57. package/dist/esm/handler/imap-compiler.js +19 -10
  58. package/dist/esm/handler/limits.d.ts +11 -0
  59. package/dist/esm/handler/limits.js +14 -0
  60. package/dist/esm/handler/parser-instance.d.ts +10 -0
  61. package/dist/esm/handler/parser-instance.js +25 -10
  62. package/dist/esm/handler/token-parser.js +37 -29
  63. package/dist/esm/imap-flow.js +235 -89
  64. package/dist/esm/package-info.d.ts +1 -1
  65. package/dist/esm/package-info.js +1 -1
  66. package/dist/esm/proxy-connection.js +7 -7
  67. package/dist/esm/search-compiler.js +30 -13
  68. package/dist/esm/special-use.js +10 -5
  69. package/dist/esm/tools.d.ts +10 -1
  70. package/dist/esm/tools.js +29 -3
  71. package/dist/esm/types.d.ts +19 -4
  72. package/package.json +1 -1
@@ -375,6 +375,15 @@ export default async function list(connection, reference, mailbox, options) {
375
375
  // Subscribed-only mailboxes that weren't in LIST are intentionally ignored
376
376
  // (they may be phantom entries from old subscriptions to deleted mailboxes).
377
377
  let runLsub = async () => {
378
+ // LIST has completed, so its entries are indexed once instead of searched per LSUB
379
+ // response, which was quadratic in the folder count. The first entry for a path wins,
380
+ // as with the linear search it replaces
381
+ let entriesByPath = new Map();
382
+ for (let entry of entries) {
383
+ if (!entriesByPath.has(entry.path)) {
384
+ entriesByPath.set(entry.path, entry);
385
+ }
386
+ }
378
387
  let response = await connection.exec('LSUB', [encodePath(connection, normalizedReference), encodePath(connection, normalizedMailbox)], {
379
388
  untagged: {
380
389
  LSUB: async (untagged) => {
@@ -400,7 +409,7 @@ export default async function list(connection, reference, mailbox, options) {
400
409
  entry.parent = entry.delimiter ? entry.path.split(entry.delimiter) : [entry.path];
401
410
  entry.name = entry.parent.pop();
402
411
  // Merge LSUB data into existing LIST entry if found
403
- let existing = entries.find(existing => existing.path === entry.path);
412
+ let existing = entriesByPath.get(entry.path);
404
413
  if (existing) {
405
414
  existing.subscribed = true;
406
415
  // Merge any additional flags from LSUB into the LIST entry
@@ -1,4 +1,4 @@
1
- import { getStatusCode, getErrorText } from '../tools.js';
1
+ import { getStatusCode, getErrorText, isServerRefusal } from '../tools.js';
2
2
  /**
3
3
  * Authenticates user using the IMAP LOGIN command.
4
4
  *
@@ -30,7 +30,11 @@ export default async function login(connection, username, password) {
30
30
  if (errorCode) {
31
31
  err.serverResponseCode = errorCode;
32
32
  }
33
- err.authenticationFailed = true;
33
+ // Only a tagged NO/BAD is the server refusing the credentials; a lost connection or a
34
+ // timeout during LOGIN says nothing about them, and a caller may retry it
35
+ if (isServerRefusal(err)) {
36
+ err.authenticationFailed = true;
37
+ }
34
38
  err.response = await getErrorText(err.response);
35
39
  throw err;
36
40
  }
@@ -1,3 +1,7 @@
1
+ import { clearTimer } from '../tools.js';
2
+ // How long to wait for the server to answer LOGOUT before closing the socket anyway. Without a
3
+ // bound of its own, an unanswered LOGOUT kept logout() pending until the socket timeout.
4
+ const LOGOUT_TIMEOUT = 10 * 1000;
1
5
  /**
2
6
  * Logs out the user and closes the connection.
3
7
  *
@@ -16,6 +20,8 @@ export default async function logout(connection) {
16
20
  return false;
17
21
  }
18
22
  let response;
23
+ // close() rejects the pending LOGOUT with NoConnection, which counts as a completed logout
24
+ let timer = setTimeout(() => connection.close(), LOGOUT_TIMEOUT);
19
25
  try {
20
26
  response = await connection.exec('LOGOUT');
21
27
  return true;
@@ -33,6 +39,7 @@ export default async function logout(connection) {
33
39
  // Set state to LOGOUT before closing to prevent any further commands from
34
40
  // being queued. The socket is closed unconditionally in this finally block
35
41
  // regardless of whether the LOGOUT command succeeded or failed.
42
+ clearTimer(timer);
36
43
  connection.state = connection.states.LOGOUT;
37
44
  if (response && typeof response.next === 'function') {
38
45
  response.next();
@@ -31,7 +31,8 @@ export default async function namespace(connection) {
31
31
  }
32
32
  let response;
33
33
  try {
34
- let map = {};
34
+ // NIL personal namespaces and a missing NAMESPACE response leave the defaults in place
35
+ let map = { personal: [], other: false, shared: false };
35
36
  response = await connection.exec('NAMESPACE', false, {
36
37
  untagged: {
37
38
  // The NAMESPACE response (RFC 2342) contains exactly three sections:
@@ -43,19 +44,22 @@ export default async function namespace(connection) {
43
44
  if (!untagged.attributes || !untagged.attributes.length) {
44
45
  return;
45
46
  }
46
- map.personal = getNamsepaceInfo(untagged.attributes[0]);
47
+ // NIL personal namespaces are legal (RFC 2342 section 5, e.g. after an anonymous login)
48
+ map.personal = getNamsepaceInfo(untagged.attributes[0]) || [];
47
49
  map.other = getNamsepaceInfo(untagged.attributes[1]);
48
50
  map.shared = getNamsepaceInfo(untagged.attributes[2]);
49
51
  }
50
52
  }
51
53
  });
54
+ // Release the response before touching the parsed data, so nothing below can leave the
55
+ // reader parked behind this command
56
+ response.next();
52
57
  connection.namespaces = map;
53
58
  // make sure that we have the first personal namespace always set
54
59
  if (!connection.namespaces.personal[0]) {
55
60
  connection.namespaces.personal[0] = { prefix: '', delimiter: '.' };
56
61
  }
57
62
  connection.namespaces.personal[0].prefix = connection.namespaces.personal[0].prefix || '';
58
- response.next();
59
63
  connection.namespace = connection.namespaces.personal[0];
60
64
  return connection.namespace;
61
65
  }
@@ -11,7 +11,9 @@ export default async function quota(connection, path) {
11
11
  // nothing to do here
12
12
  return;
13
13
  }
14
- if (!connection.capabilities.has('QUOTA')) {
14
+ // An RFC 9208 server advertises its QUOTA=RES-* resource types and does not have to list
15
+ // the bare RFC 2087 QUOTA token as well
16
+ if (!connection.capabilities.has('QUOTA') && ![...connection.capabilities.keys()].some(capability => capability.startsWith('QUOTA=RES-'))) {
15
17
  return false;
16
18
  }
17
19
  path = normalizePath(connection, path);
@@ -21,7 +21,8 @@ export default async function rename(connection, path, newPath) {
21
21
  // as IMAP servers will not rename an active mailbox.
22
22
  let selected = getSelectedMailbox(connection);
23
23
  if (selected && selected.path === path) {
24
- await connection.run('CLOSE');
24
+ // UNSELECT where possible, CLOSE would expunge messages flagged \Deleted
25
+ await connection.run('CLOSE', { unselect: true });
25
26
  }
26
27
  let response;
27
28
  try {
@@ -226,6 +226,11 @@ export default async function select(connection, pathInput, options) {
226
226
  if (!currentMailbox || currentMailbox.path !== path) {
227
227
  emitSafe(connection, 'mailboxOpen', connection.mailbox);
228
228
  }
229
+ else if (typeof map.exists === 'number' && map.exists !== currentMailbox.exists) {
230
+ // A re-SELECT of the open mailbox (the SELECT polling fallback) gets its EXISTS here
231
+ // instead of in the global handler, so it is reported the same way
232
+ emitSafe(connection, 'exists', { path, count: map.exists, prevCount: currentMailbox.exists });
233
+ }
229
234
  response.next();
230
235
  return map;
231
236
  }
@@ -1,4 +1,4 @@
1
- import { encodePath, normalizePath, buildStatusQueryAttributes, isRev2Active, isAuthenticatedState, emitSafe, getSelectedMailbox } from '../tools.js';
1
+ import { encodePath, normalizePath, buildStatusQueryAttributes, isRev2Active, isAuthenticatedState, emitSafe, getSelectedMailbox, logConnectionError } from '../tools.js';
2
2
  import { parseStatusList } from './status-fields.js';
3
3
  // STATUS fields that also refresh the live mailbox state when the queried mailbox is the
4
4
  // currently selected one. Keyed by the output property name parseStatusList() reports.
@@ -89,7 +89,12 @@ export default async function status(connection, path, query) {
89
89
  // Not a deadlock, and only reachable when the server rejects the STATUS, but a polled
90
90
  // STATUS of a missing folder ends the poll early.
91
91
  if (err.responseStatus === 'NO') {
92
- let folders = await connection.run('LIST', '', path, { listOnly: true });
92
+ // A failing probe (lost connection, throttling) answers nothing about the mailbox,
93
+ // so STATUS then fails the same way as any other failed STATUS
94
+ let folders = await connection.run('LIST', '', path, { listOnly: true }).catch((listErr) => {
95
+ logConnectionError(connection, 'Failed to check if the mailbox exists', listErr);
96
+ return false;
97
+ });
93
98
  if (folders && !folders.length) {
94
99
  let error = new Error(`Mailbox doesn't exist: ${path}`);
95
100
  error.code = 'NotFound';
@@ -16,4 +16,4 @@ export interface StoreCommandOptions extends StoreOptions {
16
16
  * @param options - Store options
17
17
  * @returns True on success, false on failure or if nothing to do
18
18
  */
19
- export default function store(connection: ImapFlow, range: string, flags: string | string[], options: StoreCommandOptions): Promise<boolean>;
19
+ export default function store(connection: ImapFlow, range: string, flags: string | string[], options?: StoreCommandOptions | undefined): Promise<boolean>;
@@ -9,13 +9,12 @@ import { formatFlag, canUseFlag, reportCommandError, getSelectedMailbox } from '
9
9
  * @returns True on success, false on failure or if nothing to do
10
10
  */
11
11
  export default async function store(connection, range, flags, options) {
12
+ options = options || {};
12
13
  let mailbox = getSelectedMailbox(connection);
13
14
  if (!mailbox || !range || (options.useLabels && !connection.capabilities.has('X-GM-EXT-1'))) {
14
15
  // nothing to do here
15
16
  return false;
16
17
  }
17
- /* c8 ignore next */ // options.useLabels is dereferenced in the guard above, so options is always defined here
18
- options = options || {};
19
18
  // Build the IMAP STORE operation name. The format is:
20
19
  // [+|-]FLAGS[.SILENT] or [+|-]X-GM-LABELS
21
20
  // Where: no prefix = replace all, + = add, - = remove
@@ -8,6 +8,9 @@ import libbase64 from 'libbase64';
8
8
  import { Headers } from '@zone-eu/mailsplit';
9
9
  import FlowedDecoder from '@zone-eu/mailsplit/lib/flowed-decoder.js';
10
10
  import { LimitedPassthrough, normalizeByteLimit } from './limited-passthrough.js';
11
+ // What a wait on the head stream listens for: it can take more input, it failed, or it went
12
+ // away (a consumer destroying it closes it without a 'drain')
13
+ const DRAIN_WAIT_EVENTS = ['drain', 'error', 'close'];
11
14
  import { getDecoder, isUnsafeKey } from './tools.js';
12
15
  /**
13
16
  * Implements ImapFlow.download(), see its documentation
@@ -47,7 +50,8 @@ export async function downloadMessage(client, range, part, options) {
47
50
  range = uid;
48
51
  downloadOptions.uid = true;
49
52
  }
50
- if (!response.bodyStructure.childNodes) {
53
+ // bodyStructure is unset when the server sent BODYSTRUCTURE NIL
54
+ if (!response.bodyStructure?.childNodes) {
51
55
  // single text message
52
56
  part = 'TEXT';
53
57
  }
@@ -290,11 +294,54 @@ export async function downloadMessage(client, range, part, options) {
290
294
  // at the bound still gets its terminating chunk. Infinity when the server reported no
291
295
  // size, which leaves the loop bounded by maxBytes alone.
292
296
  let maxTotalBytes = normalizeByteLimit(meta.expectedSize ? meta.expectedSize * 2 + chunkSize : 0);
293
- // Fetch remaining chunks in a loop, writing each to the decoder stream.
294
- // Stops when the server returns a short chunk (< chunkSize), answers with more than the
295
- // requested window, the byte limiter is satisfied, or the consumer destroys the output
296
- // stream. Throws when the ceiling above is crossed.
297
- let fetchAllParts = async () => {
297
+ // Resolves once the head stream can take more input, rejects when it fails, and resolves
298
+ // when it goes away. finish() is the listener itself: 'drain' and 'close' emit no arguments,
299
+ // 'error' emits the error, and removal needs no separate handler references. It removes
300
+ // only the listeners this wait installed - removeAllListeners('error') also took off the
301
+ // forwarder pipeStage() attached to the head stream when the pipeline was built, and the
302
+ // head must keep that forwarder for the life of the download or a chunk failure has nowhere
303
+ // to go.
304
+ let waitForDrain = () => new Promise((resolve, reject) => {
305
+ const finish = (err) => {
306
+ for (let event of DRAIN_WAIT_EVENTS) {
307
+ stream.removeListener(event, finish);
308
+ }
309
+ /* c8 ignore next 2 */ // stream error during a backpressure drain wait is timing-dependent
310
+ if (err) {
311
+ reject(err);
312
+ }
313
+ else {
314
+ resolve();
315
+ }
316
+ };
317
+ for (let event of DRAIN_WAIT_EVENTS) {
318
+ stream.once(event, finish);
319
+ }
320
+ });
321
+ // Writes a chunk and waits out the backpressure. A base64 decoder defers its write callback,
322
+ // so a chunk the size of its buffer is never taken synchronously and the head chunk waits
323
+ // here as much as any other. A stream failure during the wait is thrown unless the download
324
+ // was already aborted (the consumer destroyed the stream).
325
+ let writeAndDrain = async (chunk) => {
326
+ if (writeChunk(chunk) !== false) {
327
+ return;
328
+ }
329
+ try {
330
+ await waitForDrain();
331
+ /* c8 ignore next 5 */ // re-throw path only triggers on a stream error mid-drain, which is timing-dependent
332
+ }
333
+ catch (err) {
334
+ if (!fetchAborted) {
335
+ throw err;
336
+ }
337
+ }
338
+ };
339
+ // Writes the head chunk, then fetches the remaining chunks in a loop, writing each to the
340
+ // decoder stream. Stops when the server returns a short chunk (< chunkSize), answers with
341
+ // more than the requested window, the byte limiter is satisfied, or the consumer destroys
342
+ // the output stream. Throws when the ceiling above is crossed.
343
+ let fetchAllParts = async (head) => {
344
+ await writeAndDrain(head);
298
345
  while (hasMore && !isLimited() && !fetchAborted) {
299
346
  if (processed >= maxTotalBytes) {
300
347
  // Loud on purpose. Everything written downstream by this point holds
@@ -307,7 +354,9 @@ export async function downloadMessage(client, range, part, options) {
307
354
  throw err;
308
355
  }
309
356
  let { response, chunk } = await getNextPart();
310
- if (fetchAborted) {
357
+ // A consumer that gave up while the chunk was in flight: its 'close' may still be a
358
+ // tick away from setting fetchAborted, so the stream's own flag is checked as well
359
+ if (fetchAborted || output.destroyed) {
311
360
  break;
312
361
  }
313
362
  if (response === false) {
@@ -322,48 +371,7 @@ export async function downloadMessage(client, range, part, options) {
322
371
  if (!chunk) {
323
372
  break;
324
373
  }
325
- // Handle backpressure
326
- if (writeChunk(chunk) === false) {
327
- // Wait for drain event before continuing
328
- try {
329
- await new Promise((resolve, reject) => {
330
- // finish() is the listener itself, as settle() is for the TLS upgrade:
331
- // 'drain' and 'close' emit no arguments, 'error' emits the error, and
332
- // removal needs no separate handler references. It removes only the
333
- // three listeners this wait installed - removeAllListeners('error')
334
- // also took off the forwarder pipeStage() attached to the head stream
335
- // when the pipeline was built, and the head must keep that forwarder
336
- // for the life of the download or a chunk failure has nowhere to go.
337
- const finish = (err) => {
338
- for (let event of ['drain', 'error', 'close']) {
339
- stream.removeListener(event, finish);
340
- }
341
- /* c8 ignore next 2 */ // stream error during a backpressure drain wait is timing-dependent
342
- if (err) {
343
- reject(err);
344
- }
345
- else {
346
- resolve();
347
- }
348
- };
349
- stream.once('drain', finish);
350
- stream.once('error', finish);
351
- stream.once('close', finish);
352
- });
353
- /* c8 ignore start */ // re-throw path only triggers on a stream error mid-drain, which is timing-dependent
354
- }
355
- catch (err) {
356
- // Re-throw only if not aborted
357
- if (!fetchAborted) {
358
- throw err;
359
- }
360
- }
361
- /* c8 ignore stop */
362
- // Check if we should abort after waiting
363
- if (fetchAborted) {
364
- break;
365
- }
366
- }
374
+ await writeAndDrain(chunk);
367
375
  }
368
376
  };
369
377
  // A download is a sequence of chunk FETCHes with a backpressure wait in between. Those
@@ -382,13 +390,10 @@ export async function downloadMessage(client, range, part, options) {
382
390
  client.autoidle();
383
391
  }
384
392
  };
385
- // Kick off the download pipeline asynchronously. The first chunk was
386
- // already fetched above (to get metadata); write it to the decoder
387
- // stream and then fetch remaining chunks via fetchAllParts().
388
- // setImmediate ensures the caller gets the {meta, content} return
389
- // value before streaming begins.
390
- let runFetchAllParts = () => {
391
- fetchAllParts()
393
+ // Runs the download pipeline with the head chunk fetched above (for its metadata): it is
394
+ // written to the decoder stream and the remaining chunks follow
395
+ let runFetchAllParts = (head) => {
396
+ fetchAllParts(head)
392
397
  .catch(err => {
393
398
  if (!fetchAborted && stream && !stream.destroyed) {
394
399
  stream.emit('error', err);
@@ -420,36 +425,8 @@ export async function downloadMessage(client, range, part, options) {
420
425
  // for a routine-looking connection code.
421
426
  .catch(err => client.log.error({ msg: 'Failed to fail the download stream', err, cid: client.id }));
422
427
  };
423
- setImmediate(() => {
424
- let writeResult;
425
- try {
426
- writeResult = writeChunk(chunk);
427
- }
428
- catch (err) {
429
- stream.emit('error', err);
430
- finishDownload();
431
- /* c8 ignore next 3 */ // emitting the error above triggers cleanup (fetchAborted=true), so this end() guard is already false here
432
- if (!fetchAborted && stream && !stream.destroyed) {
433
- stream.end();
434
- }
435
- return;
436
- }
437
- /* c8 ignore next 9 */ // `stream` is piped to the limiter before this runs, so the head write drains synchronously and always returns true (verified for chunkSize up to 8MB); the drain-wait branch is unreachable
438
- if (!writeResult) {
439
- // Initial chunk filled the buffer, wait for drain
440
- stream.once('drain', () => {
441
- if (!fetchAborted) {
442
- runFetchAllParts();
443
- }
444
- else {
445
- finishDownload();
446
- }
447
- });
448
- }
449
- else {
450
- runFetchAllParts();
451
- }
452
- });
428
+ // Deferred so the caller gets the {meta, content} return value before streaming begins
429
+ setImmediate(() => runFetchAllParts(chunk));
453
430
  return {
454
431
  meta,
455
432
  content: output
@@ -469,14 +446,16 @@ export async function downloadMessageParts(client, range, parts, options) {
469
446
  // no mailbox selected, nothing to do
470
447
  return {};
471
448
  }
472
- let downloadOptions = Object.assign({
473
- chunkSize: 64 * 1024,
474
- maxBytes: Infinity
475
- }, options || {});
449
+ let downloadOptions = options || {};
450
+ // Asked as a partial fetch so at most maxBytes of each part crosses the wire, and enforced
451
+ // again on the answer for servers that ignore the partial specifier
452
+ let maxBytes = normalizeByteLimit(downloadOptions.maxBytes);
476
453
  let query = { bodyParts: [] };
477
454
  for (let part of parts) {
478
455
  query.bodyParts.push(part + '.mime');
479
- query.bodyParts.push(part);
456
+ // The partial specifier carries a 32-bit length (RFC 9051 "number"), so a cap beyond
457
+ // that is applied on the answer alone
458
+ query.bodyParts.push(maxBytes > 0xffffffff ? part : { key: part, start: 0, maxLength: maxBytes });
480
459
  }
481
460
  let response = await client.fetchOne(range, query, downloadOptions);
482
461
  if (!response || !response.bodyParts) {
@@ -494,6 +473,18 @@ export async function downloadMessageParts(client, range, parts, options) {
494
473
  if (keyParts.length === 1) {
495
474
  // content
496
475
  let key = keyParts[0];
476
+ if (content.length > maxBytes) {
477
+ // The server ignored the partial specifier and sent the whole part, the quirk
478
+ // download() tolerates the same way: keep what was asked for
479
+ client.log.warn({
480
+ msg: 'Server returned more than the requested window, truncating the part',
481
+ part: key,
482
+ maxBytes,
483
+ received: content.length,
484
+ cid: client.id
485
+ });
486
+ content = content.subarray(0, maxBytes);
487
+ }
497
488
  if (!data[key]) {
498
489
  data[key] = { content };
499
490
  }
@@ -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.
@@ -127,8 +131,8 @@ async function compiler(response, options) {
127
131
  return;
128
132
  }
129
133
  if (typeof node === 'string' || Buffer.isBuffer(node)) {
130
- if (isLogging && node.length > 100) {
131
- resp.push(emitEntry('"(* ' + node.length + 'B string *)"'));
134
+ if (isLogging && node.length > LOG_VALUE_LIMIT) {
135
+ resp.push(emitEntry(logPlaceholder(node.length, 'string')));
132
136
  }
133
137
  else {
134
138
  resp.push(emitEntry(isLogging ? JSON.stringify(node.toString()) : quoteString(node.toString())));
@@ -147,7 +151,7 @@ async function compiler(response, options) {
147
151
  switch (node.type.toUpperCase()) {
148
152
  case 'LITERAL':
149
153
  if (isLogging) {
150
- resp.push(emitEntry('"(* ' + node.value.length + 'B literal *)"'));
154
+ resp.push(emitEntry(logPlaceholder(node.value.length, 'literal')));
151
155
  }
152
156
  else {
153
157
  // The literal size marker counts octets - string values are written as
@@ -179,8 +183,8 @@ async function compiler(response, options) {
179
183
  }
180
184
  break;
181
185
  case 'STRING':
182
- if (isLogging && node.value.length > 100) {
183
- 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')));
184
188
  }
185
189
  else {
186
190
  val = (node.value || '').toString();
@@ -238,11 +242,16 @@ async function compiler(response, options) {
238
242
  case 'SECTION':
239
243
  val = (node.value || '').toString();
240
244
  if (!node.section || val) {
241
- // Verify the value contains only valid ATOM-CHAR characters.
242
- // Strip a leading backslash before checking (system flags like \Seen start with '\').
243
- // If any character fails verification, fall back to an IMAP quoted string
244
- // (JSON.stringify is used only for log output, where values are display-escaped).
245
- 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.
246
255
  val = isLogging ? JSON.stringify(val) : quoteString(val);
247
256
  }
248
257
  resp.push(emitEntry(val));
@@ -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);