imapflow 2.2.6 → 2.2.8

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 (76) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cjs/commands/append.js +10 -3
  3. package/dist/cjs/commands/authenticate.js +42 -22
  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/enable.js +6 -0
  8. package/dist/cjs/commands/esearch-parser.js +8 -2
  9. package/dist/cjs/commands/fetch.js +57 -14
  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 +8 -8
  22. package/dist/cjs/download.js +220 -95
  23. package/dist/cjs/handler/imap-compiler.js +19 -10
  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 +36 -28
  29. package/dist/cjs/imap-flow.d.ts +2 -2
  30. package/dist/cjs/imap-flow.js +256 -95
  31. package/dist/cjs/package-info.d.ts +1 -1
  32. package/dist/cjs/package-info.js +1 -1
  33. package/dist/cjs/proxy-connection.js +7 -7
  34. package/dist/cjs/search-compiler.js +33 -13
  35. package/dist/cjs/special-use.js +10 -5
  36. package/dist/cjs/tools.d.ts +14 -4
  37. package/dist/cjs/tools.js +69 -9
  38. package/dist/cjs/types.d.ts +19 -4
  39. package/dist/esm/commands/append.js +11 -4
  40. package/dist/esm/commands/authenticate.js +43 -23
  41. package/dist/esm/commands/close.d.ts +12 -1
  42. package/dist/esm/commands/close.js +5 -3
  43. package/dist/esm/commands/delete.js +2 -1
  44. package/dist/esm/commands/enable.js +6 -0
  45. package/dist/esm/commands/esearch-parser.js +8 -2
  46. package/dist/esm/commands/fetch.js +57 -14
  47. package/dist/esm/commands/id.js +9 -2
  48. package/dist/esm/commands/idle.js +16 -6
  49. package/dist/esm/commands/list.js +10 -1
  50. package/dist/esm/commands/login.js +6 -2
  51. package/dist/esm/commands/logout.js +7 -0
  52. package/dist/esm/commands/namespace.js +7 -3
  53. package/dist/esm/commands/quota.js +3 -1
  54. package/dist/esm/commands/rename.js +2 -1
  55. package/dist/esm/commands/select.js +5 -0
  56. package/dist/esm/commands/status.js +7 -2
  57. package/dist/esm/commands/store.d.ts +1 -1
  58. package/dist/esm/commands/store.js +9 -9
  59. package/dist/esm/download.js +220 -95
  60. package/dist/esm/handler/imap-compiler.js +19 -10
  61. package/dist/esm/handler/limits.d.ts +11 -0
  62. package/dist/esm/handler/limits.js +14 -0
  63. package/dist/esm/handler/parser-instance.d.ts +10 -0
  64. package/dist/esm/handler/parser-instance.js +25 -10
  65. package/dist/esm/handler/token-parser.js +37 -29
  66. package/dist/esm/imap-flow.d.ts +2 -2
  67. package/dist/esm/imap-flow.js +256 -95
  68. package/dist/esm/package-info.d.ts +1 -1
  69. package/dist/esm/package-info.js +1 -1
  70. package/dist/esm/proxy-connection.js +7 -7
  71. package/dist/esm/search-compiler.js +33 -13
  72. package/dist/esm/special-use.js +10 -5
  73. package/dist/esm/tools.d.ts +14 -4
  74. package/dist/esm/tools.js +68 -9
  75. package/dist/esm/types.d.ts +19 -4
  76. package/package.json +5 -3
@@ -241,11 +241,11 @@ const httpConnect = async ({ logger, proxyUrl, secureProxy, proxyHost, proxyPort
241
241
  * @returns An IPv4 address.
242
242
  */
243
243
  const resolveIPv4 = async (hostname, deadline) => {
244
- let addresses = await deadline.race(node_dns_1.default.promises.resolve4(hostname));
245
- if (!addresses || !addresses.length) {
246
- throw proxyError(`Could not resolve an IPv4 address for ${hostname}`, 'EPROXY');
247
- }
248
- return addresses[0];
244
+ // lookup() goes through the system resolver (getaddrinfo), so hosts files and mDNS names
245
+ // resolve the same way they would for a direct connection. It rejects (ENOTFOUND) rather
246
+ // than returning nothing when the name has no IPv4 address
247
+ let { address } = await deadline.race(node_dns_1.default.promises.lookup(hostname, { family: 4 }));
248
+ return address;
249
249
  };
250
250
  /**
251
251
  * Establishes a tunnel through a SOCKS proxy.
@@ -289,8 +289,8 @@ const socksConnect = async ({ logger, proxyUrl, protocol, proxyHost, proxyPort,
289
289
  set_tcp_nodelay: true
290
290
  };
291
291
  if (proxyUrl.username || proxyUrl.password) {
292
- connectionOpts.proxy.userId = proxyUrl.username;
293
- connectionOpts.proxy.password = proxyUrl.password;
292
+ connectionOpts.proxy.userId = decodeUserInfo(proxyUrl.username);
293
+ connectionOpts.proxy.password = decodeUserInfo(proxyUrl.password);
294
294
  }
295
295
  // The dependency treats a zero timeout as its own 30 second default, so only a strictly
296
296
  // positive remaining budget may be passed.
@@ -53,17 +53,12 @@ let toSequenceValue = (value) => [].concat(value).join(',');
53
53
  let toSearchValue = (value) => UNICODE_PATTERN.test(value) && !value.includes('\0') ? { type: 'LITERAL', value: Buffer.from(value) } : { type: 'ATOM', value };
54
54
  /**
55
55
  * Adds a search option with its value(s) to the attributes array.
56
- * Handles NOT operations and array values.
57
56
  *
58
57
  * @param attributes - Array to append the attribute to
59
58
  * @param term - The search term (e.g., 'FROM', 'SUBJECT')
60
- * @param value - The value for the search term (string, array, or falsy for NOT)
59
+ * @param value - The value for the search term (string or array)
61
60
  */
62
61
  let setOpt = (attributes, term, value) => {
63
- // Handle NOT operations for false or null values
64
- if (value === false || value === null) {
65
- attributes.push({ type: 'ATOM', value: 'NOT' });
66
- }
67
62
  attributes.push({ type: 'ATOM', value: term.toUpperCase() });
68
63
  // Handle array values (e.g. HEADER name/value pairs)
69
64
  if (Array.isArray(value)) {
@@ -253,6 +248,11 @@ const searchCompiler = (connection, query) => {
253
248
  break;
254
249
  // Email ID support (OBJECTID or Gmail extension)
255
250
  case 'EMAILID':
251
+ // A falsy value means no criterion, as for the text fields. It used to
252
+ // compile into NOT EMAILID "false", which matched every message
253
+ if (!params[term]) {
254
+ break;
255
+ }
256
256
  if (connection.capabilities.has('OBJECTID')) {
257
257
  setOpt(attributes, 'EMAILID', params[term]);
258
258
  }
@@ -260,7 +260,7 @@ const searchCompiler = (connection, query) => {
260
260
  // Fallback to Gmail message ID
261
261
  setOpt(attributes, 'X-GM-MSGID', params[term]);
262
262
  }
263
- else if (params[term]) {
263
+ else {
264
264
  // Dropping the criterion would widen the search to every message
265
265
  // matching the rest of the query, which a delete or move acts on
266
266
  fail('MissingServerExtension', 'Server does not support OBJECTID or X-GM-EXT-1 extension required for EMAILID');
@@ -268,6 +268,11 @@ const searchCompiler = (connection, query) => {
268
268
  break;
269
269
  // Thread ID support (OBJECTID or Gmail extension)
270
270
  case 'THREADID':
271
+ // A falsy value means no criterion, as for the text fields. It used to
272
+ // compile into NOT THREADID "false", which matched every message
273
+ if (!params[term]) {
274
+ break;
275
+ }
271
276
  if (connection.capabilities.has('OBJECTID')) {
272
277
  setOpt(attributes, 'THREADID', params[term]);
273
278
  }
@@ -275,7 +280,7 @@ const searchCompiler = (connection, query) => {
275
280
  // Fallback to Gmail thread ID
276
281
  setOpt(attributes, 'X-GM-THRID', params[term]);
277
282
  }
278
- else if (params[term]) {
283
+ else {
279
284
  // Dropping the criterion would widen the search to every message
280
285
  // matching the rest of the query, which a delete or move acts on
281
286
  fail('MissingServerExtension', 'Server does not support OBJECTID or X-GM-EXT-1 extension required for THREADID');
@@ -338,11 +343,11 @@ const searchCompiler = (connection, query) => {
338
343
  if (!value) {
339
344
  break;
340
345
  }
341
- // Use WITHIN extension for better timezone handling if available
342
- if (connection.capabilities.has('WITHIN')) {
343
- // Convert to seconds ago from now
344
- const now = Date.now();
345
- const withinSeconds = Math.round(Math.max(0, now - value.getTime()) / 1000);
346
+ // Use WITHIN extension for better timezone handling if available.
347
+ // The interval is an nz-number (RFC 5032), so a date that is not in the
348
+ // past (OLDER 0 would be answered with BAD) takes the date path instead
349
+ const withinSeconds = Math.round((Date.now() - value.getTime()) / 1000);
350
+ if (connection.capabilities.has('WITHIN') && withinSeconds >= 1) {
346
351
  const withinKeyword = term.toUpperCase() === 'BEFORE' ? 'OLDER' : 'YOUNGER';
347
352
  setOpt(attributes, withinKeyword, withinSeconds.toString());
348
353
  break;
@@ -362,7 +367,18 @@ const searchCompiler = (connection, query) => {
362
367
  case 'KEYWORD':
363
368
  case 'UNKEYWORD':
364
369
  {
370
+ if (typeof params[term] !== 'string') {
371
+ fail('InvalidSearchQuery', `Search value for ${term.toLowerCase()} must be a string`);
372
+ }
365
373
  let flag = (0, tools_js_1.formatFlag)(params[term]);
374
+ // formatFlag() refuses \Recent, which is not a keyword, and values that are
375
+ // not atoms. Dropping the criterion would widen the search, so the query is
376
+ // refused instead
377
+ if (flag === false) {
378
+ fail('InvalidSearchQuery', /^\\recent$/i.test(params[term])
379
+ ? `${params[term]} can not be searched as a keyword, use the "recent" search key instead`
380
+ : `${params[term]} is not a valid keyword`);
381
+ }
366
382
  // Compiled even when the mailbox does not allow the keyword: the
367
383
  // correct answer is then the empty set, which dropping the
368
384
  // criterion would turn into every message matching the rest
@@ -460,6 +476,10 @@ const searchCompiler = (connection, query) => {
460
476
  walkOrTree(genOrTree(params[term]));
461
477
  }
462
478
  break;
479
+ default:
480
+ // An unknown key is refused: dropping it silently widened the search (a
481
+ // query of only unknown keys matched every message)
482
+ fail('InvalidSearchQuery', `Unknown search key "${term}"`);
463
483
  }
464
484
  });
465
485
  };
@@ -77,6 +77,7 @@ const TOKEN_SPLIT = /[\s\-_/.,()[\]]+/;
77
77
  // we fall back to matching folder names against these lists of known
78
78
  // translations in various languages (including non-Latin scripts).
79
79
  exports.flags = ['\\All', '\\Archive', '\\Drafts', '\\Flagged', '\\Junk', '\\Sent', '\\Trash'];
80
+ const LOWERCASE_FLAGS = exports.flags.map(flag => flag.toLowerCase());
80
81
  exports.names = {
81
82
  '\\Sent': [
82
83
  'aika',
@@ -863,9 +864,10 @@ for (let flag of Object.keys(exports.names)) {
863
864
  }
864
865
  }
865
866
  // Fold a folder name into the form the name tables are stored in.
866
- // Remove U+200E (LEFT-TO-RIGHT MARK) which some mail clients (especially for
867
- // RTL languages like Arabic, Hebrew) insert into folder names for display purposes.
868
- // These invisible marks would otherwise prevent exact string matching.
867
+ // Remove U+200E and U+200F (LEFT-TO-RIGHT and RIGHT-TO-LEFT MARK) which some mail clients
868
+ // (especially for RTL languages like Arabic, Hebrew) insert into folder names for display
869
+ // purposes. These invisible marks would otherwise prevent exact string matching. U+200B
870
+ // (ZERO WIDTH SPACE) is kept, Khmer names in the tables use it as their word separator.
869
871
  // Normalize with NFKC last: the same folder name can arrive in different but
870
872
  // equivalent forms depending on the client that created it, and matching is exact.
871
873
  // NFKC rather than NFC because the compatibility folding is what maps halfwidth
@@ -874,7 +876,7 @@ for (let flag of Object.keys(exports.names)) {
874
876
  function normalizeName(name) {
875
877
  return name
876
878
  .toLowerCase()
877
- .replace(/\u200e/g, '')
879
+ .replace(/[\u200e\u200f]/g, '')
878
880
  .trim()
879
881
  .normalize('NFKC');
880
882
  }
@@ -883,7 +885,10 @@ const specialUse = (hasSpecialUseExtension, folder) => {
883
885
  // Extension-provided flags take precedence over name-based detection because they
884
886
  // are authoritative - the server explicitly marks the folder's role.
885
887
  if (hasSpecialUseExtension) {
886
- const flag = exports.flags.find(flag => folder.flags.has(flag));
888
+ // Flags are atoms and compare case-insensitively (RFC 9051 section 9)
889
+ const listed = new Set();
890
+ folder.flags.forEach(flag => listed.add(flag.toLowerCase()));
891
+ const flag = exports.flags.find((flag, i) => listed.has(LOWERCASE_FLAGS[i]));
887
892
  if (flag) {
888
893
  return { flag, source: 'extension' };
889
894
  }
@@ -119,6 +119,14 @@ export declare function unrefTimer<T extends NodeJS.Timeout | null | undefined>(
119
119
  * @param err - The error to log
120
120
  */
121
121
  export declare function logConnectionError(connection: ImapFlow, msg: string, err: ImapFlowError | null | undefined): void;
122
+ /**
123
+ * Whether a command failed because the server answered it with a tagged NO or BAD, as opposed
124
+ * to a lost connection, a timeout or a local failure that says nothing about the server's answer.
125
+ *
126
+ * @param err - The error the command failed with
127
+ * @returns True for a tagged NO or BAD
128
+ */
129
+ export declare function isServerRefusal(err: ImapFlowError | null | undefined): boolean;
122
130
  /**
123
131
  * Checks whether IMAP4rev2 semantics are active for the connection: either the
124
132
  * client enabled IMAP4rev2 explicitly, or the server is rev2-only (advertises
@@ -285,9 +293,10 @@ export declare function getColorFlags(color: string | null | undefined): {
285
293
  * @param untagged - Parsed untagged IMAP response
286
294
  * @param mailbox - Current mailbox state object
287
295
  * @param idHashAlgorithm - Hash for the fallback message id, `md5` unless the client was told otherwise
296
+ * @param connection - Connection the response arrived on, decodes Gmail labels like mailbox names
288
297
  * @returns Formatted message object with properties like seq, uid, flags, envelope, etc.
289
298
  */
290
- export declare function formatMessageResponse(untagged: ImapResponse, mailbox: MailboxObject, idHashAlgorithm?: string): Promise<FetchMessageObject>;
299
+ export declare function formatMessageResponse(untagged: ImapResponse, mailbox: MailboxObject, idHashAlgorithm?: string, connection?: ImapFlow): Promise<FetchMessageObject>;
291
300
  /**
292
301
  * Strips surrounding double quotes from a name string.
293
302
  *
@@ -356,8 +365,8 @@ export declare function formatDate(value: unknown): string | undefined;
356
365
  */
357
366
  export declare function formatDateTime(value: unknown): string | undefined;
358
367
  /**
359
- * Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent),
360
- * and capitalizes system flags properly.
368
+ * Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent) and for
369
+ * values that are not valid flags (keywords must be atoms), and capitalizes system flags properly.
361
370
  *
362
371
  * @param flag - Flag string to normalize
363
372
  * @returns Normalized flag string, or false if the flag cannot be set
@@ -407,7 +416,8 @@ export declare function isUnsafeKey(key: unknown): boolean;
407
416
  /**
408
417
  * Reads a parsed attribute list of atoms or strings (a flag list, a capability list) into
409
418
  * an array of strings. Any element can be a parsed NIL, and the list itself can be NIL,
410
- * so both levels are guarded here rather than at each call site.
419
+ * so both levels are guarded here rather than at each call site. A literal (a Gmail label
420
+ * the server could not send quoted) arrives as a Buffer and is decoded as UTF-8.
411
421
  *
412
422
  * @param list - Parsed attribute list from a response.
413
423
  * @returns The string values, in order, with unusable entries dropped.
package/dist/cjs/tools.js CHANGED
@@ -12,6 +12,7 @@ exports.guardedReject = guardedReject;
12
12
  exports.clearTimer = clearTimer;
13
13
  exports.unrefTimer = unrefTimer;
14
14
  exports.logConnectionError = logConnectionError;
15
+ exports.isServerRefusal = isServerRefusal;
15
16
  exports.isRev2Active = isRev2Active;
16
17
  exports.hasCapability = hasCapability;
17
18
  exports.buildStatusQueryAttributes = buildStatusQueryAttributes;
@@ -55,6 +56,7 @@ exports.packMessageRange = packMessageRange;
55
56
  const libmime_1 = __importDefault(require("libmime"));
56
57
  const charsets_js_1 = require("./charsets.js");
57
58
  const imap_handler_js_1 = require("./handler/imap-handler.js");
59
+ const imap_formal_syntax_js_1 = __importDefault(require("./handler/imap-formal-syntax.js"));
58
60
  const node_crypto_1 = require("node:crypto");
59
61
  const jp_decoder_js_1 = require("./jp-decoder.js");
60
62
  const iconv_lite_1 = __importDefault(require("iconv-lite"));
@@ -266,6 +268,16 @@ function logConnectionError(connection, msg, err) {
266
268
  let routine = !!err && CONNECTION_GONE_CODES.has(err.code) && !err.reason;
267
269
  connection.log[routine ? 'debug' : 'warn']({ msg, err, cid: connection.id });
268
270
  }
271
+ /**
272
+ * Whether a command failed because the server answered it with a tagged NO or BAD, as opposed
273
+ * to a lost connection, a timeout or a local failure that says nothing about the server's answer.
274
+ *
275
+ * @param err - The error the command failed with
276
+ * @returns True for a tagged NO or BAD
277
+ */
278
+ function isServerRefusal(err) {
279
+ return !!err && (err.responseStatus === 'NO' || err.responseStatus === 'BAD');
280
+ }
269
281
  /**
270
282
  * Checks whether IMAP4rev2 semantics are active for the connection: either the
271
283
  * client enabled IMAP4rev2 explicitly, or the server is rev2-only (advertises
@@ -325,7 +337,8 @@ function buildStatusQueryAttributes(connection, statusQuery) {
325
337
  }
326
338
  break;
327
339
  case 'HIGHESTMODSEQ':
328
- if (connection.capabilities.has('CONDSTORE')) {
340
+ // QRESYNC implies CONDSTORE (RFC 7162 3.2.3)
341
+ if (connection.capabilities.has('CONDSTORE') || connection.capabilities.has('QRESYNC')) {
329
342
  attributes.push({ type: 'ATOM', value: key.toUpperCase() });
330
343
  }
331
344
  break;
@@ -593,7 +606,12 @@ function getFolderTree(folders) {
593
606
  }
594
607
  return node;
595
608
  };
596
- for (let folder of folders) {
609
+ // Parents are inserted before their children, as getTreeNode() can only descend into nodes
610
+ // that already exist. LIST gives no ordering guarantee, and the LIST command sorts special-use
611
+ // mailboxes first, which put a child such as "[Gmail]/Sent Mail" ahead of "[Gmail]" and left
612
+ // it at the root. The sort is stable, so siblings keep their listing order
613
+ let byDepth = [...folders].sort((a, b) => (a.parent ? a.parent.length : 0) - (b.parent ? b.parent.length : 0));
614
+ for (let folder of byDepth) {
597
615
  let parent = getTreeNode(folder.parent);
598
616
  // see if entry already exists
599
617
  let existing = parent.folders && parent.folders.find(existing => existing.name === folder.name);
@@ -701,24 +719,40 @@ function getColorFlags(color) {
701
719
  * @param untagged - Parsed untagged IMAP response
702
720
  * @param mailbox - Current mailbox state object
703
721
  * @param idHashAlgorithm - Hash for the fallback message id, `md5` unless the client was told otherwise
722
+ * @param connection - Connection the response arrived on, decodes Gmail labels like mailbox names
704
723
  * @returns Formatted message object with properties like seq, uid, flags, envelope, etc.
705
724
  */
706
- async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
725
+ async function formatMessageResponse(untagged, mailbox, idHashAlgorithm, connection) {
707
726
  let map = {};
708
727
  // The sequence number indexes into mailbox state, so an unusable one is dropped rather
709
728
  // than coerced to NaN or Infinity
710
729
  map.seq = parseUintValue(untagged.command, exports.MAX_UINT32_DIGITS) || undefined;
711
730
  let key;
731
+ // the <origin> of a partial section ("BODY[2]<1024>"), kept for the value that follows
732
+ let origin = false;
733
+ let partialOrigins;
734
+ let recordOrigin = (sectionKey) => {
735
+ if (origin !== false) {
736
+ if (!partialOrigins) {
737
+ partialOrigins = new Map();
738
+ }
739
+ partialOrigins.set(sectionKey, origin);
740
+ }
741
+ };
712
742
  let attributes = ((untagged.attributes && untagged.attributes[1]) || []);
713
743
  for (let i = 0, len = attributes.length; i < len; i++) {
714
744
  let attribute = attributes[i];
715
745
  if (i % 2 === 0) {
746
+ origin = false;
716
747
  key = (await (0, imap_handler_js_1.compiler)({
717
748
  attributes: [attribute]
718
749
  }))
719
750
  .toString()
720
751
  .toLowerCase()
721
- .replace(/<\d+(\.\d+)?>$/, '');
752
+ .replace(/<(\d+)(\.\d+)?>$/, (match, start) => {
753
+ origin = parseUintValue(start, exports.MAX_UINT32_DIGITS);
754
+ return '';
755
+ });
722
756
  continue;
723
757
  }
724
758
  /* c8 ignore start */ // defensive: key is always a string produced by the compiler above
@@ -763,6 +797,7 @@ async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
763
797
  case 'body[]':
764
798
  case 'binary[]':
765
799
  map.source = getBuffer(attribute);
800
+ recordOrigin('');
766
801
  break;
767
802
  case 'uid':
768
803
  // A UID feeds mailbox.uidNext one line below, and from there every range
@@ -809,7 +844,8 @@ async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
809
844
  map.threadId = getString(attribute);
810
845
  break;
811
846
  case 'x-gm-labels':
812
- map.labels = new Set(getArray(attribute));
847
+ // labels are mailbox names, modified UTF-7 unless UTF-8 is enabled
848
+ map.labels = new Set(getArray(attribute).map(label => (connection ? decodePath(connection, label) : label)));
813
849
  break;
814
850
  case 'rfc822.size':
815
851
  map.size = getUint(attribute) || 0;
@@ -855,6 +891,7 @@ async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
855
891
  map.bodyParts = new Map();
856
892
  }
857
893
  map.bodyParts.set(partKey, value);
894
+ recordOrigin(partKey);
858
895
  if (match[1].toLowerCase() === 'binary') {
859
896
  // The part arrived via FETCH BINARY (RFC 3516, FETCH side folded
860
897
  // into IMAP4rev2), so the server has already removed the
@@ -896,6 +933,10 @@ async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
896
933
  .update([path, mailbox.uidValidity?.toString() || '', map.uid.toString()].join(':'))
897
934
  .digest('hex');
898
935
  }
936
+ if (partialOrigins) {
937
+ // non-enumerable, so it stays out of logged and serialized fetch results
938
+ Object.defineProperty(map, 'partialOrigins', { value: partialOrigins, writable: true, configurable: true });
939
+ }
899
940
  if (map.flags) {
900
941
  let flagColor = getFlagColor(map.flags);
901
942
  if (flagColor) {
@@ -1338,14 +1379,22 @@ function formatDateTime(value) {
1338
1379
  let timeStr = date.toISOString().substring(11, 19);
1339
1380
  return `${dateStr} ${timeStr} +0000`;
1340
1381
  }
1382
+ // the memoized ATOM-CHAR set of RFC 9051 section 9
1383
+ const atomChars = imap_formal_syntax_js_1.default['ATOM-CHAR'];
1341
1384
  /**
1342
- * Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent),
1343
- * and capitalizes system flags properly.
1385
+ * Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent) and for
1386
+ * values that are not valid flags (keywords must be atoms), and capitalizes system flags properly.
1344
1387
  *
1345
1388
  * @param flag - Flag string to normalize
1346
1389
  * @returns Normalized flag string, or false if the flag cannot be set
1347
1390
  */
1348
1391
  function formatFlag(flag) {
1392
+ // RFC 9051 section 9: flag-keyword is an atom and flag-extension is "\\" atom, the same check
1393
+ // the compiler uses to decide what it can send unquoted
1394
+ let atom = flag.charAt(0) === '\\' ? flag.slice(1) : flag;
1395
+ if (!atom || imap_formal_syntax_js_1.default.verify(atom, atomChars()) >= 0) {
1396
+ return false;
1397
+ }
1349
1398
  switch (flag.toLowerCase()) {
1350
1399
  case '\\recent':
1351
1400
  // can not set or remove
@@ -1412,7 +1461,8 @@ function isUnsafeKey(key) {
1412
1461
  /**
1413
1462
  * Reads a parsed attribute list of atoms or strings (a flag list, a capability list) into
1414
1463
  * an array of strings. Any element can be a parsed NIL, and the list itself can be NIL,
1415
- * so both levels are guarded here rather than at each call site.
1464
+ * so both levels are guarded here rather than at each call site. A literal (a Gmail label
1465
+ * the server could not send quoted) arrives as a Buffer and is decoded as UTF-8.
1416
1466
  *
1417
1467
  * @param list - Parsed attribute list from a response.
1418
1468
  * @returns The string values, in order, with unusable entries dropped.
@@ -1421,7 +1471,17 @@ function getStringList(list) {
1421
1471
  if (!Array.isArray(list)) {
1422
1472
  return [];
1423
1473
  }
1424
- return list.map(entry => (entry && typeof entry.value === 'string' ? entry.value : false)).filter(entry => entry);
1474
+ let strings = [];
1475
+ for (let entry of list) {
1476
+ let value = entry && entry.value;
1477
+ if (Buffer.isBuffer(value)) {
1478
+ value = value.toString();
1479
+ }
1480
+ if (value && typeof value === 'string') {
1481
+ strings.push(value);
1482
+ }
1483
+ }
1484
+ return strings;
1425
1485
  }
1426
1486
  /**
1427
1487
  * Parses an untrusted decimal value from a server response into a BigInt.
@@ -155,7 +155,15 @@ export interface ImapFlowOptions {
155
155
  * 30 minutes. Set to 0 or false to disable.
156
156
  */
157
157
  maxLockHoldTime?: number | false | undefined;
158
- /** If true, uses TLS. If false, uses cleartext. If not set, upgrades to TLS if available */
158
+ /**
159
+ * STARTTLS policy for a connection that was not opened with `secure: true`. If true, the
160
+ * connection must upgrade to TLS, and connecting fails when the server does not offer
161
+ * STARTTLS. If false, the connection stays in cleartext. If not set, the connection upgrades
162
+ * when the server offers STARTTLS and otherwise continues in cleartext with a warning logged:
163
+ * the capability list that decides this was itself received in cleartext, so an attacker on
164
+ * the path can strip it. Set `doSTARTTLS: true` whenever the credentials must never cross the
165
+ * wire unencrypted.
166
+ */
159
167
  doSTARTTLS?: boolean | undefined;
160
168
  /** Custom instance ID string for logs */
161
169
  id?: string | undefined;
@@ -646,8 +654,8 @@ export interface DownloadOptions {
646
654
  /** How large content parts to ask from the server. Defaults to 65536 */
647
655
  chunkSize?: number | undefined;
648
656
  }
649
- /** Options for downloadMany(), the same shape as the download() options */
650
- export type DownloadManyOptions = DownloadOptions;
657
+ /** Options for downloadMany(): the download() options without `chunkSize`, as the parts come in one FETCH */
658
+ export type DownloadManyOptions = Pick<DownloadOptions, 'uid' | 'maxBytes'>;
651
659
  export interface DownloadManyPart {
652
660
  meta: DownloadMeta;
653
661
  content?: Buffer | null | undefined;
@@ -769,6 +777,13 @@ export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
769
777
  */
770
778
  export type InternalLogger = {
771
779
  [level in LogLevel]: (obj: any) => void;
780
+ } & {
781
+ /**
782
+ * Whether an entry logged at this level reaches anyone (the logger, or a 'log' event
783
+ * listener). Set on the logger the connection builds; a logger assigned from outside may
784
+ * lack it, and every level then counts as enabled
785
+ */
786
+ isLevelEnabled?: ((level: LogLevel) => boolean) | undefined;
772
787
  };
773
788
  export interface LogEvent {
774
789
  /** Log level */
@@ -802,7 +817,7 @@ export interface ESearchResult {
802
817
  partial?: {
803
818
  /** The requested range, e.g. "1:100" */
804
819
  range: string;
805
- /** Matching UIDs in that range as compact sequence-set */
820
+ /** Matching UIDs in that range as compact sequence-set, an empty string when the range lies past the end of the results */
806
821
  messages: string;
807
822
  } | undefined;
808
823
  /** Highest mod-sequence of the matching messages (RFC 7162, present when the search used a modseq criterion on a CONDSTORE session) */
@@ -1,4 +1,4 @@
1
- import { formatFlag, canUseFlag, formatDateTime, normalizePath, encodePath, comparePaths, parseBigIntValue, parseUintValue, MAX_UINT32_DIGITS, getSelectedMailbox, emitSafe, isAuthenticatedState, reportCommandError } from '../tools.js';
1
+ import { formatFlag, canUseFlag, formatDateTime, normalizePath, encodePath, comparePaths, parseBigIntValue, parseUintValue, MAX_UINT32_DIGITS, getSelectedMailbox, emitSafe, isAuthenticatedState, reportCommandError, logConnectionError } from '../tools.js';
2
2
  /**
3
3
  * Appends a message to a mailbox.
4
4
  *
@@ -155,10 +155,17 @@ export default async function append(connection, destination, content, flags, id
155
155
  }
156
156
  // If we have a sequence number but no UID (server doesn't support UIDPLUS),
157
157
  // look up the UID via SEARCH to provide a consistent result to the caller.
158
+ // The message is already stored, so a failed lookup only leaves the UID out:
159
+ // rejecting here would make a retrying caller append it twice.
158
160
  if (map.seq && !map.uid) {
159
- let list = await connection.search({ seq: map.seq }, { uid: true });
160
- if (Array.isArray(list) && list.length) {
161
- map.uid = list[0];
161
+ try {
162
+ let list = await connection.search({ seq: map.seq }, { uid: true });
163
+ if (Array.isArray(list) && list.length) {
164
+ map.uid = list[0];
165
+ }
166
+ }
167
+ catch (err) {
168
+ logConnectionError(connection, 'Failed to look up the UID of the appended message', err);
162
169
  }
163
170
  }
164
171
  return map;
@@ -1,4 +1,4 @@
1
- import { getStatusCode, getErrorText } from '../tools.js';
1
+ import { getStatusCode, getErrorText, isServerRefusal } from '../tools.js';
2
2
  /**
3
3
  * Handles authentication errors by enriching the error object with server response details.
4
4
  *
@@ -11,7 +11,10 @@ async function handleAuthError(err, errorResponse) {
11
11
  if (errorCode) {
12
12
  err.serverResponseCode = errorCode;
13
13
  }
14
- err.authenticationFailed = true;
14
+ // Only a tagged NO/BAD is the server refusing the credentials, see login.ts
15
+ if (isServerRefusal(err)) {
16
+ err.authenticationFailed = true;
17
+ }
15
18
  err.response = await getErrorText(err.response);
16
19
  if (errorResponse) {
17
20
  err.oauthError = errorResponse;
@@ -60,15 +63,26 @@ async function authOauth(connection, username, accessToken) {
60
63
  // Empty breaker: XOAUTH2 expects an empty response to abort the SASL exchange
61
64
  breaker = '';
62
65
  }
66
+ let encoded = Buffer.from(oauthbearer).toString('base64');
67
+ // Without SASL-IR the payload may not ride on the command line (RFC 4959 section 3): it is
68
+ // sent as the answer to the first, empty continuation request instead
69
+ let payloadPending = !connection.capabilities.has('SASL-IR');
63
70
  let errorResponse = false;
64
71
  try {
65
- let response = await connection.exec('AUTHENTICATE', [
66
- { type: 'ATOM', value: command },
67
- { type: 'ATOM', value: Buffer.from(oauthbearer).toString('base64'), sensitive: true }
68
- ], {
72
+ let attributes = [{ type: 'ATOM', value: command }];
73
+ if (!payloadPending) {
74
+ attributes.push({ type: 'ATOM', value: encoded, sensitive: true });
75
+ }
76
+ let response = await connection.exec('AUTHENTICATE', attributes, {
69
77
  // Server sends a "+" continuation if auth fails, with a base64 JSON error payload.
70
78
  // We decode it for diagnostics, then send the breaker to terminate the exchange.
71
79
  onPlusTag: async (resp) => {
80
+ if (payloadPending) {
81
+ payloadPending = false;
82
+ connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded response for AUTH=${command}`, cid: connection.id });
83
+ connection.write(encoded);
84
+ return;
85
+ }
72
86
  if (resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT') {
73
87
  try {
74
88
  errorResponse = JSON.parse(Buffer.from(resp.attributes[0].value, 'base64').toString());
@@ -108,27 +122,33 @@ async function authLogin(connection, username, password) {
108
122
  try {
109
123
  // SASL LOGIN is a challenge-response mechanism: the server sends base64-encoded
110
124
  // prompts ("Username:" and "Password:") and the client responds with base64-encoded values.
125
+ let usernameSent = false;
111
126
  let response = await connection.exec('AUTHENTICATE', [{ type: 'ATOM', value: 'LOGIN' }], {
112
127
  onPlusTag: async (resp) => {
113
- if (resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT') {
114
- // Decode the server's base64 challenge to determine what it's asking for.
115
- // Strip trailing colons and null bytes (\x00) that some servers append to the prompt.
116
- let question = Buffer.from(resp.attributes[0].value, 'base64')
128
+ // Decode the server's base64 challenge to determine what it's asking for.
129
+ // Strip trailing colons and null bytes (\x00) that some servers append to the prompt.
130
+ let question = resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT'
131
+ ? Buffer.from(resp.attributes[0].value, 'base64')
117
132
  .toString()
118
133
  .toLowerCase()
119
- .replace(/[:\x00]*$/, '');
120
- if (question === 'username' || question === 'user name') {
121
- let encodedUsername = Buffer.from(username).toString('base64');
122
- connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN`, cid: connection.id });
123
- connection.write(encodedUsername);
124
- }
125
- else if (question === 'password') {
126
- connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN`, cid: connection.id });
127
- connection.write(Buffer.from(password).toString('base64'));
128
- }
129
- else {
130
- throw new Error(`Unknown LOGIN question "${question}"`);
131
- }
134
+ .replace(/[:\x00]*$/, '')
135
+ : '';
136
+ // Some servers send an empty first challenge, which by SASL LOGIN convention asks for the username
137
+ if (question === 'username' || question === 'user name' || (!question && !usernameSent)) {
138
+ let encodedUsername = Buffer.from(username).toString('base64');
139
+ connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN`, cid: connection.id });
140
+ connection.write(encodedUsername);
141
+ usernameSent = true;
142
+ }
143
+ else if (question === 'password') {
144
+ connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN`, cid: connection.id });
145
+ connection.write(Buffer.from(password).toString('base64'));
146
+ }
147
+ else {
148
+ // Cancel the exchange (RFC 9051 section 6.2.2), so the server fails the command
149
+ // with a tagged BAD instead of waiting for an answer that never comes
150
+ connection.log.warn({ msg: 'Unknown AUTH=LOGIN challenge, cancelling', question, cid: connection.id });
151
+ connection.write('*');
132
152
  }
133
153
  }
134
154
  });
@@ -1,8 +1,19 @@
1
1
  import type { ImapFlow } from '../imap-flow.js';
2
+ /**
3
+ * Options for the CLOSE command
4
+ */
5
+ export interface CloseCommandOptions {
6
+ /**
7
+ * Only deselect the mailbox: use UNSELECT (RFC 3691, folded into IMAP4rev2) when the server
8
+ * supports it, so messages flagged \Deleted are not expunged as a side effect. Falls back to CLOSE
9
+ */
10
+ unselect?: boolean | undefined;
11
+ }
2
12
  /**
3
13
  * Closes the currently selected mailbox.
4
14
  *
5
15
  * @param connection - IMAP connection instance
16
+ * @param options - Close options
6
17
  * @returns True on success, false on failure, or undefined if not in SELECTED state
7
18
  */
8
- export default function close(connection: ImapFlow): Promise<boolean | undefined>;
19
+ export default function close(connection: ImapFlow, options?: CloseCommandOptions | undefined): Promise<boolean | undefined>;
@@ -1,11 +1,12 @@
1
- import { emitSafe } from '../tools.js';
1
+ import { emitSafe, hasCapability } from '../tools.js';
2
2
  /**
3
3
  * Closes the currently selected mailbox.
4
4
  *
5
5
  * @param connection - IMAP connection instance
6
+ * @param options - Close options
6
7
  * @returns True on success, false on failure, or undefined if not in SELECTED state
7
8
  */
8
- export default async function close(connection) {
9
+ export default async function close(connection, options) {
9
10
  if (connection.state !== connection.states.SELECTED) {
10
11
  // nothing to do here
11
12
  return;
@@ -15,7 +16,8 @@ export default async function close(connection) {
15
16
  // IMAP CLOSE (RFC 3501 6.4.2): permanently removes all messages flagged \Deleted
16
17
  // from the currently selected mailbox (implicit expunge) and deselects it.
17
18
  // Unlike EXPUNGE, CLOSE does not send individual untagged EXPUNGE responses.
18
- response = await connection.exec('CLOSE');
19
+ // UNSELECT deselects the same way without removing anything.
20
+ response = await connection.exec(options?.unselect && hasCapability(connection, 'UNSELECT') ? 'UNSELECT' : 'CLOSE');
19
21
  response.next();
20
22
  // Transition from SELECTED back to AUTHENTICATED state.
21
23
  // Clear mailbox metadata so subsequent operations know no mailbox is selected.