imapflow 1.2.7 → 1.2.9

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 (54) hide show
  1. package/.ncurc.js +1 -1
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +3 -3
  5. package/lib/charsets.js +15 -0
  6. package/lib/commands/append.js +62 -54
  7. package/lib/commands/authenticate.js +99 -52
  8. package/lib/commands/capability.js +12 -2
  9. package/lib/commands/close.js +11 -1
  10. package/lib/commands/compress.js +10 -1
  11. package/lib/commands/copy.js +18 -1
  12. package/lib/commands/create.js +15 -2
  13. package/lib/commands/delete.js +10 -1
  14. package/lib/commands/enable.js +12 -1
  15. package/lib/commands/expunge.js +18 -2
  16. package/lib/commands/fetch.js +39 -4
  17. package/lib/commands/id.js +22 -3
  18. package/lib/commands/idle.js +39 -4
  19. package/lib/commands/list.js +86 -48
  20. package/lib/commands/login.js +12 -1
  21. package/lib/commands/logout.js +11 -2
  22. package/lib/commands/move.js +17 -1
  23. package/lib/commands/namespace.js +32 -2
  24. package/lib/commands/noop.js +6 -1
  25. package/lib/commands/quota.js +33 -14
  26. package/lib/commands/rename.js +13 -1
  27. package/lib/commands/search.js +16 -1
  28. package/lib/commands/select.js +76 -33
  29. package/lib/commands/starttls.js +6 -1
  30. package/lib/commands/status.js +64 -52
  31. package/lib/commands/store.js +27 -4
  32. package/lib/commands/subscribe.js +7 -1
  33. package/lib/commands/unsubscribe.js +7 -1
  34. package/lib/handler/imap-compiler.js +44 -2
  35. package/lib/handler/imap-formal-syntax.js +51 -3
  36. package/lib/handler/imap-handler.js +8 -0
  37. package/lib/handler/imap-parser.js +23 -2
  38. package/lib/handler/imap-stream.js +84 -31
  39. package/lib/handler/parser-instance.js +61 -1
  40. package/lib/handler/token-parser.js +66 -9
  41. package/lib/imap-commands.js +11 -0
  42. package/lib/imap-flow.d.ts +6 -0
  43. package/lib/imap-flow.js +164 -42
  44. package/lib/jp-decoder.js +10 -0
  45. package/lib/limited-passthrough.js +12 -5
  46. package/lib/proxy-connection.js +18 -12
  47. package/lib/search-compiler.js +3 -11
  48. package/lib/special-use.js +23 -16
  49. package/lib/tools.js +218 -13
  50. package/package.json +4 -11
  51. package/test/commands-integration-test.js +33 -0
  52. package/test/special-use-test.js +32 -0
  53. package/assets/favicon.ico +0 -0
  54. package/jsdoc.json +0 -28
package/lib/tools.js CHANGED
@@ -11,11 +11,21 @@ const iconv = require('iconv-lite');
11
11
 
12
12
  const FLAG_COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'grey'];
13
13
 
14
+ /**
15
+ * Error subclass thrown when IMAP authentication fails.
16
+ */
14
17
  class AuthenticationFailure extends Error {
15
18
  authenticationFailed = true;
16
19
  }
17
20
 
18
21
  const tools = {
22
+ /**
23
+ * Encodes a mailbox path to modified UTF-7 if the server does not support UTF8=ACCEPT.
24
+ *
25
+ * @param {Object} connection - IMAP connection instance
26
+ * @param {String} path - Mailbox path to encode
27
+ * @returns {String} Encoded mailbox path
28
+ */
19
29
  encodePath(connection, path) {
20
30
  path = (path || '').toString();
21
31
  if (!connection.enabled.has('UTF8=ACCEPT') && /[&\x00-\x08\x0b-\x0c\x0e-\x1f\u0080-\uffff]/.test(path)) {
@@ -28,6 +38,13 @@ const tools = {
28
38
  return path;
29
39
  },
30
40
 
41
+ /**
42
+ * Decodes a mailbox path from modified UTF-7 if the server does not support UTF8=ACCEPT.
43
+ *
44
+ * @param {Object} connection - IMAP connection instance
45
+ * @param {String} path - Mailbox path to decode
46
+ * @returns {String} Decoded mailbox path
47
+ */
31
48
  decodePath(connection, path) {
32
49
  path = (path || '').toString();
33
50
  if (!connection.enabled.has('UTF8=ACCEPT') && /[&]/.test(path)) {
@@ -40,6 +57,15 @@ const tools = {
40
57
  return path;
41
58
  },
42
59
 
60
+ /**
61
+ * Normalizes a mailbox path by joining array segments with the namespace delimiter,
62
+ * uppercasing INBOX, and prepending the namespace prefix if needed.
63
+ *
64
+ * @param {Object} connection - IMAP connection instance
65
+ * @param {String|String[]} path - Mailbox path or array of path segments
66
+ * @param {Boolean} [skipNamespace] - If true, skips prepending the namespace prefix
67
+ * @returns {String} Normalized mailbox path
68
+ */
43
69
  normalizePath(connection, path, skipNamespace) {
44
70
  if (Array.isArray(path)) {
45
71
  path = path.join((connection.namespace && connection.namespace.delimiter) || '');
@@ -58,6 +84,14 @@ const tools = {
58
84
  return path;
59
85
  },
60
86
 
87
+ /**
88
+ * Compares two mailbox paths for equality after normalization.
89
+ *
90
+ * @param {Object} connection - IMAP connection instance
91
+ * @param {String} a - First mailbox path
92
+ * @param {String} b - Second mailbox path
93
+ * @returns {Boolean} True if the paths are equal after normalization
94
+ */
61
95
  comparePaths(connection, a, b) {
62
96
  if (!a || !b) {
63
97
  return false;
@@ -65,6 +99,12 @@ const tools = {
65
99
  return tools.normalizePath(connection, a) === tools.normalizePath(connection, b);
66
100
  },
67
101
 
102
+ /**
103
+ * Parses a capability response list into a Map of capability names to values.
104
+ *
105
+ * @param {Array} list - Array of capability objects from IMAP response
106
+ * @returns {Map<string, boolean|number>} Map of capability names to `true` or numeric values
107
+ */
68
108
  updateCapabilities(list) {
69
109
  let map = new Map();
70
110
 
@@ -96,6 +136,13 @@ const tools = {
96
136
 
97
137
  AuthenticationFailure,
98
138
 
139
+ /**
140
+ * Extracts the IMAP response status code (e.g. AUTHENTICATIONFAILED, NONEXISTENT)
141
+ * from a parsed server response.
142
+ *
143
+ * @param {Object} response - Parsed IMAP server response
144
+ * @returns {String|false} Uppercase status code string, or false if not found
145
+ */
99
146
  getStatusCode(response) {
100
147
  return response &&
101
148
  response.attributes &&
@@ -107,6 +154,12 @@ const tools = {
107
154
  : false;
108
155
  },
109
156
 
157
+ /**
158
+ * Compiles an IMAP response object back into a human-readable string.
159
+ *
160
+ * @param {Object} response - Parsed IMAP server response
161
+ * @returns {Promise<String|false>} Compiled response text, or false if no response
162
+ */
110
163
  async getErrorText(response) {
111
164
  if (!response) {
112
165
  return false;
@@ -115,6 +168,12 @@ const tools = {
115
168
  return (await compiler(response)).toString();
116
169
  },
117
170
 
171
+ /**
172
+ * Enhances an IMAP command error with the server response code and text.
173
+ *
174
+ * @param {Error} err - Error object with a `response` property
175
+ * @returns {Promise<Error>} The enhanced error with `serverResponseCode` and string `response`
176
+ */
118
177
  async enhanceCommandError(err) {
119
178
  let errorCode = tools.getStatusCode(err.response);
120
179
  if (errorCode) {
@@ -124,6 +183,12 @@ const tools = {
124
183
  return err;
125
184
  },
126
185
 
186
+ /**
187
+ * Converts a flat list of mailbox folders into a tree structure.
188
+ *
189
+ * @param {Object[]} folders - Array of folder objects from LIST/LSUB response
190
+ * @returns {Object} Tree structure with a `root` flag and nested `folders` arrays
191
+ */
127
192
  getFolderTree(folders) {
128
193
  let tree = {
129
194
  root: true,
@@ -212,11 +277,21 @@ const tools = {
212
277
  return tree;
213
278
  },
214
279
 
280
+ /**
281
+ * Derives a flag color name from a message's flags Set using Apple Mail color flag rules.
282
+ *
283
+ * @param {Set<string>} flags - Message flags Set
284
+ * @returns {String|null} Color name (e.g. 'red', 'orange') or null if not flagged
285
+ */
215
286
  getFlagColor(flags) {
216
287
  if (!flags.has('\\Flagged')) {
217
288
  return null;
218
289
  }
219
290
 
291
+ // Apple Mail encodes flag colors as a 3-bit value using $MailFlagBit0/1/2 keywords.
292
+ // Bit 0 = 1, Bit 1 = 2, Bit 2 = 4. The resulting integer (0-6) indexes into FLAG_COLORS:
293
+ // 0=red, 1=orange, 2=yellow, 3=green, 4=blue, 5=purple, 6=grey.
294
+ // Value 7 (all bits set) is unused; defaults to red.
220
295
  const bit0 = flags.has('$MailFlagBit0') ? 1 : 0;
221
296
  const bit1 = flags.has('$MailFlagBit1') ? 2 : 0;
222
297
  const bit2 = flags.has('$MailFlagBit2') ? 4 : 0;
@@ -226,19 +301,30 @@ const tools = {
226
301
  return FLAG_COLORS[color] || 'red'; // default to red for the unused \b111
227
302
  },
228
303
 
304
+ /**
305
+ * Converts a color name to the corresponding flag add/remove operations for Apple Mail color flags.
306
+ *
307
+ * @param {String} color - Color name (e.g. 'red', 'orange', 'yellow')
308
+ * @returns {Object|null} Object with `add` and `remove` arrays of flag strings, or null if invalid color
309
+ */
229
310
  getColorFlags(color) {
311
+ // Reverse mapping from a color name to the Apple Mail $MailFlagBit0/1/2 flags.
312
+ // Returns an object with 'add' and 'remove' arrays so the caller can STORE +FLAGS/-FLAGS.
230
313
  const colorCode = color ? FLAG_COLORS.indexOf((color || '').toString().toLowerCase().trim()) : null;
231
314
  if (colorCode < 0 && colorCode !== null) {
232
315
  return null;
233
316
  }
234
317
 
318
+ // Decompose color index back into its 3-bit representation
235
319
  const bits = [];
236
320
  bits[0] = colorCode & 1; // eslint-disable-line no-bitwise
237
321
  bits[1] = colorCode & 2; // eslint-disable-line no-bitwise
238
322
  bits[2] = colorCode & 4; // eslint-disable-line no-bitwise
239
323
 
324
+ // If colorCode is truthy (non-zero), add \Flagged; if zero/null, remove \Flagged
240
325
  let result = { add: colorCode ? ['\\Flagged'] : [], remove: colorCode ? [] : ['\\Flagged'] };
241
326
 
327
+ // For each bit, add the corresponding $MailFlagBitN if set, remove it if unset
242
328
  for (let i = 0; i < bits.length; i++) {
243
329
  if (bits[i]) {
244
330
  result.add.push(`$MailFlagBit${i}`);
@@ -249,6 +335,13 @@ const tools = {
249
335
  return result;
250
336
  },
251
337
 
338
+ /**
339
+ * Formats a raw untagged FETCH response into a structured message object.
340
+ *
341
+ * @param {Object} untagged - Parsed untagged IMAP response
342
+ * @param {Object} mailbox - Current mailbox state object
343
+ * @returns {Promise<Object>} Formatted message object with properties like seq, uid, flags, envelope, etc.
344
+ */
252
345
  async formatMessageResponse(untagged, mailbox) {
253
346
  let map = {};
254
347
 
@@ -309,25 +402,33 @@ const tools = {
309
402
 
310
403
  case 'uid':
311
404
  map.uid = Number(getString(attribute));
405
+ // If the UID we just saw is >= the mailbox's uidNext, bump uidNext.
406
+ // This keeps the local uidNext estimate current without requiring a
407
+ // separate STATUS command, handling cases where new messages arrived
408
+ // since the last SELECT/EXAMINE.
312
409
  if (map.uid && (!mailbox.uidNext || mailbox.uidNext <= map.uid)) {
313
- // current uidNext seems to be outdated, bump it
314
410
  mailbox.uidNext = map.uid + 1;
315
411
  }
316
412
  break;
317
413
 
318
414
  case 'modseq':
319
415
  map.modseq = BigInt(getArray(attribute)[0]);
416
+ // Similarly, keep the local highestModseq estimate up to date.
417
+ // This is critical for CONDSTORE/QRESYNC delta syncing.
320
418
  if (map.modseq && (!mailbox.highestModseq || mailbox.highestModseq < map.modseq)) {
321
- // current highestModseq seems to be outdated, bump it
322
419
  mailbox.highestModseq = map.modseq;
323
420
  }
324
421
  break;
325
422
 
326
423
  case 'emailid':
424
+ // OBJECTID extension (RFC 8474): server-assigned stable email identifier
327
425
  map.emailId = getArray(attribute)[0];
328
426
  break;
329
427
 
330
428
  case 'x-gm-msgid':
429
+ // Gmail extension: X-GM-MSGID is Gmail's unique message ID.
430
+ // Mapped to the same emailId field as OBJECTID for a unified API,
431
+ // but this is a Gmail-specific numeric string, not an RFC 8474 ObjectID.
331
432
  map.emailId = getString(attribute);
332
433
  break;
333
434
 
@@ -423,6 +524,12 @@ const tools = {
423
524
  return map;
424
525
  },
425
526
 
527
+ /**
528
+ * Strips surrounding double quotes from a name string.
529
+ *
530
+ * @param {String} name - Raw name string potentially wrapped in quotes
531
+ * @returns {String} Name with surrounding quotes removed
532
+ */
426
533
  processName(name) {
427
534
  name = (name || '').toString();
428
535
  if (name.length > 2 && name.at(0) === '"' && name.at(-1) === '"') {
@@ -431,6 +538,12 @@ const tools = {
431
538
  return name;
432
539
  },
433
540
 
541
+ /**
542
+ * Parses a raw IMAP ENVELOPE response into a structured envelope object.
543
+ *
544
+ * @param {Array} entry - Raw envelope data array from IMAP response
545
+ * @returns {Object} Parsed envelope with date, subject, from, to, cc, bcc, messageId, etc.
546
+ */
434
547
  parseEnvelope(entry) {
435
548
  let getStrValue = obj => {
436
549
  if (!obj) {
@@ -510,11 +623,19 @@ const tools = {
510
623
  return envelope;
511
624
  },
512
625
 
626
+ /**
627
+ * Parses structured MIME parameter arrays (including RFC 2231 continuations)
628
+ * into a flat key-value object.
629
+ *
630
+ * @param {Array} arr - Raw parameter array from BODYSTRUCTURE response
631
+ * @returns {Object} Key-value object of decoded parameters
632
+ */
513
633
  getStructuredParams(arr) {
514
634
  let key;
515
635
 
516
636
  let params = {};
517
637
 
638
+ // BODYSTRUCTURE parameters come as flat key/value pairs: [key1, val1, key2, val2, ...]
518
639
  [].concat(arr || []).forEach((val, j) => {
519
640
  if (j % 2) {
520
641
  params[key] = libmime.decodeWords(((val && val.value) || '').toString());
@@ -523,6 +644,8 @@ const tools = {
523
644
  }
524
645
  });
525
646
 
647
+ // Detect RFC 2231 encoded filenames that were placed in the plain 'filename' param
648
+ // instead of 'filename*'. The pattern charset'language'encoded_value indicates encoding.
526
649
  if (params.filename && !params['filename*'] && /^[a-z\-_0-9]+'[a-z]*'[^'\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]+/.test(params.filename)) {
527
650
  // seems like encoded value
528
651
  let [encoding, , encodedValue] = params.filename.split("'");
@@ -531,12 +654,15 @@ const tools = {
531
654
  }
532
655
  }
533
656
 
534
- // preprocess values
657
+ // RFC 2231 parameter continuations: parameters like filename*0, filename*1, etc.
658
+ // are split parts of a single value. Parameters ending with '*' contain charset info.
659
+ // This pass collects continuation parts and groups them by their base key name.
535
660
  Object.keys(params).forEach(key => {
536
661
  let actualKey;
537
662
  let nr;
538
663
  let value;
539
664
 
665
+ // Match keys ending with *N or *N* (where N is the continuation index)
540
666
  let match = key.match(/\*((\d+)\*?)?$/);
541
667
 
542
668
  if (!match) {
@@ -556,6 +682,7 @@ const tools = {
556
682
 
557
683
  value = params[key];
558
684
 
685
+ // The first segment (*0*) may contain charset and language: charset'language'value
559
686
  if (nr === 0 && match[0].charAt(match[0].length - 1) === '*' && (match = value.match(/^([^']*)'[^']*'(.*)$/))) {
560
687
  params[actualKey].charset = match[1] || 'utf-8';
561
688
  value = match[2];
@@ -567,7 +694,9 @@ const tools = {
567
694
  delete params[key];
568
695
  });
569
696
 
570
- // concatenate split rfc2231 strings and convert encoded strings to mime encoded words
697
+ // Reassemble split RFC 2231 strings by sorting continuation parts and joining them.
698
+ // For charset-encoded values, convert URL-encoded (%XX) sequences to MIME quoted-printable
699
+ // format (=?charset?Q?...?=) so libmime.decodeWords can decode them to Unicode.
571
700
  Object.keys(params).forEach(key => {
572
701
  let value;
573
702
  if (params[key] && Array.isArray(params[key].values)) {
@@ -577,7 +706,10 @@ const tools = {
577
706
  .join('');
578
707
 
579
708
  if (params[key].charset) {
580
- // convert "%AB" to "=?charset?Q?=AB?=" and then to unicode
709
+ // Convert URL encoding (%AB) to MIME quoted-printable (=AB) by:
710
+ // 1. Escaping QP-special chars (=, ?, _, space) as %XX
711
+ // 2. Replacing all '%' with '=' to switch from URL encoding to QP encoding
712
+ // 3. Wrapping in =?charset?Q?...?= for libmime to decode
581
713
  params[key] = libmime.decodeWords(
582
714
  '=?' +
583
715
  params[key].charset +
@@ -605,7 +737,16 @@ const tools = {
605
737
  return params;
606
738
  },
607
739
 
740
+ /**
741
+ * Parses a raw IMAP BODYSTRUCTURE response into a structured tree of body parts.
742
+ *
743
+ * @param {Array} entry - Raw BODYSTRUCTURE data array from IMAP response
744
+ * @returns {Object} Parsed body structure tree with part numbers, types, parameters, and child nodes
745
+ */
608
746
  parseBodystructure(entry) {
747
+ // Recursively walks the BODYSTRUCTURE tree, building MIME part numbers.
748
+ // Part numbers follow the IMAP dot-notation: "1", "1.1", "2.3", etc.
749
+ // The root multipart has no part number; its children start at 1.
609
750
  let walk = (node, path) => {
610
751
  path = path || [];
611
752
 
@@ -613,13 +754,15 @@ const tools = {
613
754
  i = 0,
614
755
  part = 0;
615
756
 
757
+ // Build the dot-separated part number from the path array (e.g., [1,2] -> "1.2")
616
758
  if (path.length) {
617
759
  curNode.part = path.join('.');
618
760
  }
619
761
 
620
- // multipart
762
+ // multipart: first elements are arrays (child body parts), followed by the subtype string
621
763
  if (Array.isArray(node[0])) {
622
764
  curNode.childNodes = [];
765
+ // Each child array is a nested body part; increment part counter for each
623
766
  while (Array.isArray(node[i])) {
624
767
  curNode.childNodes.push(walk(node[i], path.concat(++part)));
625
768
  i++;
@@ -672,9 +815,11 @@ const tools = {
672
815
  i++;
673
816
 
674
817
  if (curNode.type === 'message/rfc822') {
675
- // message/rfc adds additional envelope, bodystructure and line count values
818
+ // message/rfc822 is special in IMAP BODYSTRUCTURE: after the standard
819
+ // 7 fields, it includes an embedded envelope, a nested bodystructure,
820
+ // and a line count for the encapsulated message.
676
821
 
677
- // envelope
822
+ // envelope of the encapsulated message
678
823
  if (node[i]) {
679
824
  curNode.envelope = tools.parseEnvelope([].concat(node[i] || []));
680
825
  }
@@ -682,9 +827,10 @@ const tools = {
682
827
 
683
828
  if (node[i]) {
684
829
  curNode.childNodes = [
685
- // rfc822 bodyparts share the same path, difference is between MIME and HEADER
686
- // path.MIME returns message/rfc822 header
687
- // path.HEADER returns inlined message header
830
+ // The nested bodystructure reuses the same path (not path+1) because
831
+ // the encapsulated message shares the part number with its wrapper.
832
+ // Distinction is via suffixes: path.MIME = wrapper headers,
833
+ // path.HEADER = encapsulated message headers.
688
834
  walk(node[i], path)
689
835
  ];
690
836
  }
@@ -698,12 +844,13 @@ const tools = {
698
844
  }
699
845
 
700
846
  if (/^text\//.test(curNode.type)) {
701
- // text/* adds additional line count value
847
+ // Per RFC 3501, text/* parts include an additional line count field after size.
848
+ // However, some servers omit this field, producing 11 elements instead of 12+.
702
849
 
703
850
  // NB! some less known servers do not include the line count value
704
851
  // length should be 12+
705
852
  if (node.length === 11 && Array.isArray(node[i + 1]) && !Array.isArray(node[i + 2])) {
706
- // invalid structure, disposition params are shifted
853
+ // invalid structure, disposition params are shifted -- skip the line count
707
854
  } else {
708
855
  // correct structure, line count number is provided
709
856
  if (node[i]) {
@@ -763,10 +910,22 @@ const tools = {
763
910
  return walk(entry);
764
911
  },
765
912
 
913
+ /**
914
+ * Checks if a value is a Date object.
915
+ *
916
+ * @param {*} obj - Value to check
917
+ * @returns {Boolean} True if the value is a Date object
918
+ */
766
919
  isDate(obj) {
767
920
  return Object.prototype.toString.call(obj) === '[object Date]';
768
921
  },
769
922
 
923
+ /**
924
+ * Converts a value to a valid Date object, or returns null.
925
+ *
926
+ * @param {*} value - Date object or date string to convert
927
+ * @returns {Date|null} Valid Date object, or null if conversion fails
928
+ */
770
929
  toValidDate(value) {
771
930
  if (!value) {
772
931
  return null;
@@ -780,6 +939,12 @@ const tools = {
780
939
  return value;
781
940
  },
782
941
 
942
+ /**
943
+ * Formats a date value into IMAP date format (DD-Mon-YYYY).
944
+ *
945
+ * @param {Date|String} value - Date to format
946
+ * @returns {String|undefined} Formatted date string, or undefined if invalid
947
+ */
783
948
  formatDate(value) {
784
949
  value = tools.toValidDate(value);
785
950
  if (!value) {
@@ -795,6 +960,12 @@ const tools = {
795
960
  return dateParts.join('-');
796
961
  },
797
962
 
963
+ /**
964
+ * Formats a date value into IMAP date-time format (DD-Mon-YYYY HH:MM:SS +0000).
965
+ *
966
+ * @param {Date|String} value - Date to format
967
+ * @returns {String|undefined} Formatted date-time string, or undefined if invalid
968
+ */
798
969
  formatDateTime(value) {
799
970
  value = tools.toValidDate(value);
800
971
  if (!value) {
@@ -807,6 +978,13 @@ const tools = {
807
978
  return `${dateStr} ${timeStr} +0000`;
808
979
  },
809
980
 
981
+ /**
982
+ * Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent),
983
+ * and capitalizes system flags properly.
984
+ *
985
+ * @param {String} flag - Flag string to normalize
986
+ * @returns {String|false} Normalized flag string, or false if the flag cannot be set
987
+ */
810
988
  formatFlag(flag) {
811
989
  switch (flag.toLowerCase()) {
812
990
  case '\\recent':
@@ -823,10 +1001,23 @@ const tools = {
823
1001
  return flag;
824
1002
  },
825
1003
 
1004
+ /**
1005
+ * Checks if a flag can be used in the given mailbox based on permanent flags.
1006
+ *
1007
+ * @param {Object} mailbox - Mailbox object with permanentFlags
1008
+ * @param {String} flag - Flag to check
1009
+ * @returns {Boolean} True if the flag is allowed
1010
+ */
826
1011
  canUseFlag(mailbox, flag) {
827
1012
  return !mailbox || !mailbox.permanentFlags || mailbox.permanentFlags.has('\\*') || mailbox.permanentFlags.has(flag);
828
1013
  },
829
1014
 
1015
+ /**
1016
+ * Expands an IMAP sequence range string (e.g. "1:3,5,7:9") into an array of numbers.
1017
+ *
1018
+ * @param {String} range - IMAP sequence range string
1019
+ * @returns {Number[]} Array of expanded sequence numbers
1020
+ */
830
1021
  expandRange(range) {
831
1022
  return range.split(',').flatMap(entry => {
832
1023
  entry = entry.trim();
@@ -853,6 +1044,13 @@ const tools = {
853
1044
  });
854
1045
  },
855
1046
 
1047
+ /**
1048
+ * Returns a stream decoder for the given charset. Uses a special Japanese
1049
+ * charset decoder for JIS/ISO-2022-JP, otherwise delegates to iconv-lite.
1050
+ *
1051
+ * @param {String} [charset='ascii'] - Character set name
1052
+ * @returns {Object} A stream decoder (Transform stream) for the charset
1053
+ */
856
1054
  getDecoder(charset) {
857
1055
  charset = (charset || 'ascii').toString().trim().toLowerCase();
858
1056
  if (/^jis|^iso-?2022-?jp|^EUCJP/i.test(charset)) {
@@ -863,6 +1061,13 @@ const tools = {
863
1061
  return iconv.decodeStream(charset);
864
1062
  },
865
1063
 
1064
+ /**
1065
+ * Packs an array of message sequence numbers into a compact IMAP range string
1066
+ * (e.g. [1,2,3,5,7,8] becomes "1:3,5,7:8").
1067
+ *
1068
+ * @param {Number|Number[]} list - Sequence number or array of sequence numbers
1069
+ * @returns {String} Packed IMAP sequence range string
1070
+ */
866
1071
  packMessageRange(list) {
867
1072
  if (!Array.isArray(list)) {
868
1073
  list = [].concat(list || []);
package/package.json CHANGED
@@ -1,16 +1,12 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
7
7
  "scripts": {
8
8
  "test": "grunt",
9
9
  "coverage": "c8 --reporter=text --reporter=html npx nodeunit test/*-test.js",
10
- "prepare": "npm run build",
11
- "docs": "rm -rf docs && mkdir -p docs && jsdoc lib/imap-flow.js -c jsdoc.json -R README.md --destination docs/ && cp assets/favicon.ico docs",
12
- "build": "npm run docs",
13
- "st": "npm run docs && st -d docs -i index.html",
14
10
  "update": "rm -rf node_modules package-lock.json && ncu -u && npm install",
15
11
  "format": "prettier --write \"**/*.{js,json,md,yml,yaml}\" --ignore-path .prettierignore",
16
12
  "lint": "eslint ."
@@ -34,10 +30,10 @@
34
30
  "@babel/eslint-parser": "7.28.6",
35
31
  "@babel/eslint-plugin": "7.27.1",
36
32
  "@babel/plugin-syntax-class-properties": "7.12.13",
37
- "@babel/preset-env": "7.28.6",
33
+ "@babel/preset-env": "7.29.0",
38
34
  "@eslint/eslintrc": "3.3.3",
39
35
  "@eslint/js": "9.39.2",
40
- "@types/node": "25.0.10",
36
+ "@types/node": "25.2.1",
41
37
  "c8": "10.1.3",
42
38
  "eslint": "9.39.2",
43
39
  "eslint-config-nodemailer": "1.2.0",
@@ -46,11 +42,8 @@
46
42
  "grunt-cli": "1.5.0",
47
43
  "grunt-contrib-nodeunit": "5.0.0",
48
44
  "grunt-eslint": "24.3.0",
49
- "imapflow-jsdoc-template": "3.4.0-imapflow.3",
50
- "jsdoc": "4.0.4",
51
45
  "prettier": "3.8.1",
52
46
  "proxyquire": "^2.1.3",
53
- "st": "3.0.3",
54
47
  "typescript": "5.9.3"
55
48
  },
56
49
  "dependencies": {
@@ -60,7 +53,7 @@
60
53
  "libbase64": "1.3.0",
61
54
  "libmime": "5.3.7",
62
55
  "libqp": "2.1.1",
63
- "nodemailer": "7.0.12",
56
+ "nodemailer": "8.0.0",
64
57
  "pino": "10.3.0",
65
58
  "socks": "2.8.7"
66
59
  }
@@ -3858,6 +3858,39 @@ module.exports['Commands: list sort fallback path comparison'] = async test => {
3858
3858
  test.done();
3859
3859
  };
3860
3860
 
3861
+ module.exports['Commands: list STATUS handles unknown key in response'] = async test => {
3862
+ const connection = createMockConnection({
3863
+ state: 3,
3864
+ capabilities: new Map([
3865
+ ['SPECIAL-USE', true],
3866
+ ['LIST-STATUS', true]
3867
+ ]),
3868
+ exec: async (cmd, attrs, opts) => {
3869
+ if (cmd === 'LIST' && opts && opts.untagged) {
3870
+ if (opts.untagged.LIST) {
3871
+ await opts.untagged.LIST({
3872
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'TestFolder' }]
3873
+ });
3874
+ }
3875
+ if (opts.untagged.STATUS) {
3876
+ await opts.untagged.STATUS({
3877
+ attributes: [{ value: 'TestFolder' }, [{ value: 'XUNKNOWN' }, { value: '999' }, { value: 'MESSAGES' }, { value: '10' }]]
3878
+ });
3879
+ }
3880
+ }
3881
+ return { next: () => {} };
3882
+ }
3883
+ });
3884
+
3885
+ const result = await listCommand(connection, '', '*', { statusQuery: { messages: true } });
3886
+ const folder = result.find(e => e.path === 'TestFolder');
3887
+ test.ok(folder);
3888
+ test.ok(folder.status);
3889
+ test.equal(folder.status.messages, 10);
3890
+ test.equal(folder.status.XUNKNOWN, undefined); // Unknown keys silently ignored
3891
+ test.done();
3892
+ };
3893
+
3861
3894
  // ============================================
3862
3895
  // SELECT Command Tests
3863
3896
  // ============================================
@@ -47,3 +47,35 @@ module.exports['Special Use: Junk folder names'] = test => {
47
47
  test.ok(junkNames.includes('junk'));
48
48
  test.done();
49
49
  };
50
+
51
+ // ============================================
52
+ // specialUse() function branch tests
53
+ // ============================================
54
+
55
+ module.exports['Special Use: specialUse returns extension flag when extension enabled and flag found'] = test => {
56
+ const result = specialUse.specialUse(true, { flags: new Set(['\\Sent']), name: 'Foo' });
57
+ test.equal(result.flag, '\\Sent');
58
+ test.equal(result.source, 'extension');
59
+ test.done();
60
+ };
61
+
62
+ module.exports['Special Use: specialUse falls back to name when extension enabled but no flag'] = test => {
63
+ const result = specialUse.specialUse(true, { flags: new Set(), name: 'Sent' });
64
+ test.equal(result.flag, '\\Sent');
65
+ test.equal(result.source, 'name');
66
+ test.done();
67
+ };
68
+
69
+ module.exports['Special Use: specialUse matches by name when extension disabled'] = test => {
70
+ const result = specialUse.specialUse(false, { flags: new Set(), name: 'Drafts' });
71
+ test.equal(result.flag, '\\Drafts');
72
+ test.equal(result.source, 'name');
73
+ test.done();
74
+ };
75
+
76
+ module.exports['Special Use: specialUse returns null flag when no match'] = test => {
77
+ const result = specialUse.specialUse(false, { flags: new Set(), name: 'CustomFolder' });
78
+ test.equal(result.flag, null);
79
+ test.equal(result.source, undefined);
80
+ test.done();
81
+ };
Binary file
package/jsdoc.json DELETED
@@ -1,28 +0,0 @@
1
- {
2
- "templates": {
3
- "referenceTitle": "ImapFlow",
4
- "disableSort": false,
5
- "collapse": true,
6
- "resources": {
7
- "Source Code": "https://github.com/postalsys/imapflow"
8
- },
9
- "cleverLinks": true,
10
- "monospaceLinks": false,
11
- "default": {
12
- "outputSourceFiles": false
13
- },
14
- "search": {
15
- "apiKey": "082c6635b32d44ed095369a5f1c790fd",
16
- "indexName": "imapflow",
17
- "hitsPerPage": 7
18
- }
19
- },
20
- "plugins": ["plugins/markdown"],
21
- "opts": {
22
- "destination": "./docs/",
23
- "encoding": "utf8",
24
- "private": true,
25
- "recurse": true,
26
- "template": "./node_modules/imapflow-jsdoc-template"
27
- }
28
- }