imapflow 1.4.8 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.github/workflows/test.yml +20 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/CLAUDE.md +12 -5
  5. package/Gruntfile.js +3 -1
  6. package/lib/commands/authenticate.js +8 -3
  7. package/lib/commands/enable.js +13 -4
  8. package/lib/commands/expunge.js +2 -2
  9. package/lib/commands/fetch.js +18 -14
  10. package/lib/commands/idle.js +6 -3
  11. package/lib/commands/list.js +241 -61
  12. package/lib/commands/move.js +2 -2
  13. package/lib/commands/namespace.js +3 -1
  14. package/lib/commands/search.js +88 -13
  15. package/lib/commands/status.js +19 -26
  16. package/lib/handler/imap-compiler.js +12 -9
  17. package/lib/handler/token-parser.js +7 -0
  18. package/lib/imap-flow.d.ts +19 -3
  19. package/lib/imap-flow.js +58 -9
  20. package/lib/search-compiler.js +15 -1
  21. package/lib/tools.js +173 -9
  22. package/package.json +3 -2
  23. package/test/commands-branches-test.js +11 -4
  24. package/test/commands-integration-test.js +1528 -108
  25. package/test/connection-edge-cases-test.js +4 -40
  26. package/test/fixtures/test-tls.js +2 -2
  27. package/test/handler-branches-test.js +4 -3
  28. package/test/imap-compiler-test.js +85 -0
  29. package/test/imap-flow-coverage-test.js +8 -1
  30. package/test/imap-flow-fetch-download-test.js +57 -4
  31. package/test/imap-flow-internals-test.js +2 -2
  32. package/test/imap-flow-methods-test.js +65 -6
  33. package/test/imap-flow-secure-test.js +25 -11
  34. package/test/imap-flow-server-test.js +80 -0
  35. package/test/imap-parser-test.js +113 -3
  36. package/test/imap-stream-test.js +46 -0
  37. package/test/integration/README.md +52 -0
  38. package/test/integration/dovecot-test.conf +27 -0
  39. package/test/integration/rev2-live-test.js +367 -0
  40. package/test/integration/run-rev2-tests.sh +75 -0
  41. package/test/reliability-improvements-test.js +4 -1
  42. package/test/search-compiler-test.js +36 -0
  43. package/test/search-test.js +52 -54
  44. package/test/tools-test.js +176 -19
package/lib/tools.js CHANGED
@@ -11,6 +11,35 @@ const iconv = require('iconv-lite');
11
11
 
12
12
  const FLAG_COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'grey'];
13
13
 
14
+ // Upper bound for expanding a single server-supplied sequence range (see
15
+ // expandRange). 2^24 entries is far beyond any legitimate mailbox while keeping
16
+ // the worst-case expansion of a hostile range bounded.
17
+ const EXPANDED_RANGE_LIMIT = 0x1000000;
18
+
19
+ // Extensions that RFC 9051 (IMAP4rev2) folds into the base protocol (Appendix E).
20
+ // When IMAP4rev2 is active, these are available even without their own capability
21
+ // token. BINARY is deliberately excluded - RFC 9051 only folds in the FETCH side,
22
+ // which fetch.js handles with its own isRev2Active check, while the APPEND side
23
+ // stays gated on the BINARY token. The set mirrors the Appendix E list in full,
24
+ // including entries no call site consults yet, so any future capability check
25
+ // gets the rev2 folding for free.
26
+ const IMAP4REV2_FOLDED_CAPABILITIES = new Set([
27
+ 'ENABLE',
28
+ 'ESEARCH',
29
+ 'IDLE',
30
+ 'LIST-EXTENDED',
31
+ 'LIST-STATUS',
32
+ 'LITERAL-',
33
+ 'MOVE',
34
+ 'NAMESPACE',
35
+ 'SASL-IR',
36
+ 'SEARCHRES',
37
+ 'SPECIAL-USE',
38
+ 'STATUS=SIZE',
39
+ 'UIDPLUS',
40
+ 'UNSELECT'
41
+ ]);
42
+
14
43
  /**
15
44
  * Error subclass thrown when IMAP authentication fails.
16
45
  */
@@ -19,6 +48,98 @@ class AuthenticationFailure extends Error {
19
48
  }
20
49
 
21
50
  const tools = {
51
+ /**
52
+ * Checks whether IMAP4rev2 semantics are active for the connection: either the
53
+ * client enabled IMAP4rev2 explicitly, or the server is rev2-only (advertises
54
+ * IMAP4rev2 without IMAP4rev1), in which case rev2 is the base protocol without
55
+ * any ENABLE (RFC 9051 Appendix A). UTF-8 mailbox names apply in both cases.
56
+ *
57
+ * @param {Object} connection - IMAP connection instance
58
+ * @returns {Boolean} True if IMAP4rev2 semantics apply to this session
59
+ */
60
+ isRev2Active(connection) {
61
+ return connection.enabled.has('IMAP4REV2') || (connection.capabilities.has('IMAP4rev2') && !connection.capabilities.has('IMAP4rev1'));
62
+ },
63
+
64
+ /**
65
+ * Checks a capability, accounting for extensions that RFC 9051 folds into base
66
+ * IMAP4rev2. Falls back to the plain capability lookup on IMAP4rev1 sessions,
67
+ * so behavior against rev1 servers is unchanged.
68
+ *
69
+ * @param {Object} connection - IMAP connection instance
70
+ * @param {String} capability - Capability name, e.g. 'UIDPLUS'
71
+ * @returns {Boolean} True if the capability (or its rev2-folded equivalent) is available
72
+ */
73
+ hasCapability(connection, capability) {
74
+ if (connection.capabilities.has(capability)) {
75
+ return true;
76
+ }
77
+ return IMAP4REV2_FOLDED_CAPABILITIES.has(capability) && tools.isRev2Active(connection);
78
+ },
79
+
80
+ /**
81
+ * Builds the attribute list for a STATUS request - the standalone STATUS command
82
+ * or the LIST-STATUS return option - from a status query object. Items the current
83
+ * session cannot request (RECENT under IMAP4rev2, HIGHESTMODSEQ without CONDSTORE)
84
+ * are silently dropped.
85
+ *
86
+ * @param {Object} connection - IMAP connection instance
87
+ * @param {Object} statusQuery - Status data items to request, e.g. {messages: true}
88
+ * @returns {Object[]} Attribute token list for the command compiler
89
+ */
90
+ buildStatusQueryAttributes(connection, statusQuery) {
91
+ let attributes = [];
92
+
93
+ Object.keys(statusQuery || {}).forEach(key => {
94
+ if (!statusQuery[key]) {
95
+ return;
96
+ }
97
+
98
+ switch (key.toUpperCase()) {
99
+ case 'MESSAGES':
100
+ case 'UIDNEXT':
101
+ case 'UIDVALIDITY':
102
+ case 'UNSEEN':
103
+ attributes.push({ type: 'ATOM', value: key.toUpperCase() });
104
+ break;
105
+
106
+ case 'RECENT':
107
+ // RECENT was removed in IMAP4rev2 (RFC 9051) - requesting it from a
108
+ // rev2 session would get the whole STATUS request rejected
109
+ if (!tools.isRev2Active(connection)) {
110
+ attributes.push({ type: 'ATOM', value: key.toUpperCase() });
111
+ }
112
+ break;
113
+
114
+ case 'HIGHESTMODSEQ':
115
+ if (connection.capabilities.has('CONDSTORE')) {
116
+ attributes.push({ type: 'ATOM', value: key.toUpperCase() });
117
+ }
118
+ break;
119
+
120
+ case 'SIZE':
121
+ // STATUS SIZE requires the STATUS=SIZE extension (RFC 8438), which
122
+ // RFC 9051 folds into base IMAP4rev2
123
+ if (tools.hasCapability(connection, 'STATUS=SIZE')) {
124
+ attributes.push({ type: 'ATOM', value: key.toUpperCase() });
125
+ }
126
+ break;
127
+
128
+ case 'DELETED':
129
+ // STATUS DELETED is a base IMAP4rev2 addition (RFC 9051 Appendix E
130
+ // item 3) with no standalone capability - requesting it from a plain
131
+ // rev1 server would get the whole STATUS request rejected. RFC 9208
132
+ // additionally makes it mandatory when QUOTA=RES-MESSAGE is advertised.
133
+ if (tools.isRev2Active(connection) || connection.capabilities.has('QUOTA=RES-MESSAGE')) {
134
+ attributes.push({ type: 'ATOM', value: key.toUpperCase() });
135
+ }
136
+ break;
137
+ }
138
+ });
139
+
140
+ return attributes;
141
+ },
142
+
22
143
  /**
23
144
  * Encodes a mailbox path to modified UTF-7 if the server does not support UTF8=ACCEPT.
24
145
  *
@@ -28,7 +149,7 @@ const tools = {
28
149
  */
29
150
  encodePath(connection, path) {
30
151
  path = (path || '').toString();
31
- if (!connection.enabled.has('UTF8=ACCEPT') && /[&\x00-\x08\x0b-\x0c\x0e-\x1f\u0080-\uffff]/.test(path)) {
152
+ if (!connection.enabled.has('UTF8=ACCEPT') && !tools.isRev2Active(connection) && /[&\x00-\x08\x0b-\x0c\x0e-\x1f\u0080-\uffff]/.test(path)) {
32
153
  try {
33
154
  path = iconv.encode(path, 'utf-7-imap').toString();
34
155
  } catch {
@@ -47,7 +168,7 @@ const tools = {
47
168
  */
48
169
  decodePath(connection, path) {
49
170
  path = (path || '').toString();
50
- if (!connection.enabled.has('UTF8=ACCEPT') && /[&]/.test(path)) {
171
+ if (!connection.enabled.has('UTF8=ACCEPT') && !tools.isRev2Active(connection) && /[&]/.test(path)) {
51
172
  try {
52
173
  path = iconv.decode(Buffer.from(path), 'utf-7-imap').toString();
53
174
  } catch {
@@ -120,6 +241,11 @@ const tools = {
120
241
  return;
121
242
  }
122
243
 
244
+ if (capability === 'IMAP4REV2') {
245
+ map.set('IMAP4rev2', true);
246
+ return;
247
+ }
248
+
123
249
  if (capability.startsWith('APPENDLIMIT=')) {
124
250
  let splitPos = capability.indexOf('=');
125
251
  let appendLimit = Number(capability.substr(splitPos + 1)) || 0;
@@ -222,7 +348,7 @@ const tools = {
222
348
  existing.path = folder.path;
223
349
  existing.subscribed = !!folder.subscribed;
224
350
  existing.listed = !!folder.listed;
225
- existing.status = !!folder.status;
351
+ existing.status = folder.status;
226
352
 
227
353
  if (folder.specialUse) {
228
354
  existing.specialUse = folder.specialUse;
@@ -242,7 +368,7 @@ const tools = {
242
368
  path: folder.path,
243
369
  subscribed: !!folder.subscribed,
244
370
  listed: !!folder.listed,
245
- status: !!folder.status
371
+ status: folder.status
246
372
  };
247
373
 
248
374
  if (folder.delimiter) {
@@ -484,6 +610,19 @@ const tools = {
484
610
  map.bodyParts = new Map();
485
611
  }
486
612
  map.bodyParts.set(partKey, value);
613
+
614
+ if (match[1].toLowerCase() === 'binary') {
615
+ // The part arrived via FETCH BINARY (RFC 3516, FETCH side folded
616
+ // into IMAP4rev2), so the server has already removed the
617
+ // content-transfer-encoding - consumers must not decode it again.
618
+ // Recorded from the actual response, not predicted from the
619
+ // request, so it stays correct even if a server answers a BINARY
620
+ // request with a BODY response or vice versa.
621
+ if (!map.binaryParts) {
622
+ map.binaryParts = new Set();
623
+ }
624
+ map.binaryParts.add(partKey);
625
+ }
487
626
  break;
488
627
  }
489
628
  break;
@@ -1016,9 +1155,28 @@ const tools = {
1016
1155
  return !mailbox || !mailbox.permanentFlags || mailbox.permanentFlags.has('\\*') || mailbox.permanentFlags.has(flag);
1017
1156
  },
1018
1157
 
1158
+ /**
1159
+ * Checks that a value is a valid IMAP sequence number or UID: a non-zero
1160
+ * 32-bit unsigned integer (nz-number in the RFC 9051 grammar). Guards range
1161
+ * expansion against untrusted server input such as 'Infinity' or '0:*'.
1162
+ *
1163
+ * @param {Number} value - Value to check
1164
+ * @returns {Boolean} True if the value is a valid sequence number/UID
1165
+ */
1166
+ isValidSequenceValue(value) {
1167
+ return Number.isSafeInteger(value) && value > 0 && value <= 0xffffffff;
1168
+ },
1169
+
1019
1170
  /**
1020
1171
  * Expands an IMAP sequence range string (e.g. "1:3,5,7:9") into an array of numbers.
1021
1172
  *
1173
+ * Entries with endpoints that are not valid nz-numbers are skipped - the input
1174
+ * may come from an untrusted server, and 'Infinity' or similar garbage would
1175
+ * otherwise loop without bound. A single range is expanded to at most
1176
+ * EXPANDED_RANGE_LIMIT entries: legitimate responses never reach the limit
1177
+ * (the mailbox would need that many messages), while a hostile range like
1178
+ * 1:4294967295 is cut off instead of exhausting memory.
1179
+ *
1022
1180
  * @param {String} range - IMAP sequence range string
1023
1181
  * @returns {Number[]} Array of expanded sequence numbers
1024
1182
  */
@@ -1027,20 +1185,26 @@ const tools = {
1027
1185
  entry = entry.trim();
1028
1186
  let colon = entry.indexOf(':');
1029
1187
  if (colon < 0) {
1030
- return Number(entry) || 0;
1188
+ let value = Number(entry);
1189
+ return tools.isValidSequenceValue(value) ? value : [];
1190
+ }
1191
+ let first = Number(entry.substr(0, colon));
1192
+ let second = Number(entry.substr(colon + 1));
1193
+ if (!tools.isValidSequenceValue(first) || !tools.isValidSequenceValue(second)) {
1194
+ return [];
1031
1195
  }
1032
- let first = Number(entry.substr(0, colon)) || 0;
1033
- let second = Number(entry.substr(colon + 1)) || 0;
1034
1196
  if (first === second) {
1035
1197
  return first;
1036
1198
  }
1037
1199
  let list = [];
1038
1200
  if (first < second) {
1039
- for (let i = first; i <= second; i++) {
1201
+ let last = Math.min(second, first + EXPANDED_RANGE_LIMIT - 1);
1202
+ for (let i = first; i <= last; i++) {
1040
1203
  list.push(i);
1041
1204
  }
1042
1205
  } else {
1043
- for (let i = first; i >= second; i--) {
1206
+ let last = Math.max(second, first - EXPANDED_RANGE_LIMIT + 1);
1207
+ for (let i = first; i >= last; i--) {
1044
1208
  list.push(i);
1045
1209
  }
1046
1210
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.4.8",
3
+ "version": "1.5.0",
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
+ "test:rev2": "bash test/integration/run-rev2-tests.sh",
10
11
  "update": "rm -rf node_modules package-lock.json && ncu -u && npm install",
11
12
  "format": "prettier --write \"**/*.{js,json,md,yml,yaml}\" --ignore-path .prettierignore",
12
13
  "lint": "eslint ."
@@ -37,7 +38,7 @@
37
38
  "grunt-cli": "1.5.0",
38
39
  "grunt-contrib-nodeunit": "5.0.0",
39
40
  "grunt-eslint": "26.0.0",
40
- "prettier": "3.9.5",
41
+ "prettier": "3.9.6",
41
42
  "proxyquire": "^2.1.3",
42
43
  "typescript": "7.0.2"
43
44
  },
@@ -415,7 +415,7 @@ module.exports['Branches: search with undefined options uses {} fallback'] = asy
415
415
  // operand of the OR is evaluated and used to skip it.
416
416
  // ============================================================================
417
417
 
418
- module.exports['Branches: search ESEARCH without uid emits SEARCH and skips LIST-typed tag'] = async test => {
418
+ module.exports['Branches: search ESEARCH without uid emits SEARCH and skips the tag correlator'] = async test => {
419
419
  let execCmd = null;
420
420
  const connection = createMockConnection({
421
421
  state: 3,
@@ -423,10 +423,17 @@ module.exports['Branches: search ESEARCH without uid emits SEARCH and skips LIST
423
423
  exec: async (cmd, attrs, opts) => {
424
424
  execCmd = cmd;
425
425
  if (opts && opts.untagged && opts.untagged.ESEARCH) {
426
- // attrs[0] is a LIST-typed object (not a plain Array) -> exercises
427
- // the `attrs[start].type === 'LIST'` branch on line 150.
426
+ // attrs[0] is the (TAG "...") correlator group, represented by the
427
+ // parser as a plain Array - it must be skipped before the keywords
428
428
  await opts.untagged.ESEARCH({
429
- attributes: [{ type: 'LIST' }, { type: 'ATOM', value: 'COUNT' }, { type: 'ATOM', value: '7' }]
429
+ attributes: [
430
+ [
431
+ { type: 'ATOM', value: 'TAG' },
432
+ { type: 'STRING', value: 'A1' }
433
+ ],
434
+ { type: 'ATOM', value: 'COUNT' },
435
+ { type: 'ATOM', value: '7' }
436
+ ]
430
437
  });
431
438
  }
432
439
  return { next: () => {} };