imapflow 1.4.8 → 1.4.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.
@@ -1,8 +1,24 @@
1
1
  'use strict';
2
2
 
3
- const { enhanceCommandError } = require('../tools.js');
3
+ const { enhanceCommandError, hasCapability, isValidSequenceValue } = require('../tools.js');
4
4
  const { searchCompiler } = require('../search-compiler.js');
5
5
 
6
+ /**
7
+ * Strips the leading (TAG "X") correlator list and the optional UID atom from an
8
+ * ESEARCH untagged response, leaving only the result keyword/value pairs.
9
+ * The IMAP parser represents parenthesized groups as plain Arrays, not objects
10
+ * with type: 'LIST'.
11
+ *
12
+ * @param {Array} attrs - Raw attribute array from the IMAP parser
13
+ * @returns {Array} Attribute array starting at the first result keyword
14
+ */
15
+ const stripEsearchPrefix = attrs => {
16
+ let start = 0;
17
+ if (attrs[start] && Array.isArray(attrs[start])) start++;
18
+ if (attrs[start] && typeof attrs[start].value === 'string' && attrs[start].value.toUpperCase() === 'UID') start++;
19
+ return attrs.slice(start);
20
+ };
21
+
6
22
  /**
7
23
  * Parses the key-value attributes from an ESEARCH untagged response.
8
24
  *
@@ -54,9 +70,7 @@ function parseEsearchResponse(attrs) {
54
70
  }
55
71
  case 'PARTIAL': {
56
72
  const listToken = attrs[++i];
57
- // Parser represents parenthesized groups as plain Arrays,
58
- // but check both forms for robustness.
59
- const items = Array.isArray(listToken) ? listToken : listToken && Array.isArray(listToken.attributes) ? listToken.attributes : null;
73
+ const items = Array.isArray(listToken) ? listToken : null;
60
74
  if (!items || items.length < 2) break;
61
75
  result.partial = {
62
76
  range: items[0].value,
@@ -113,7 +127,8 @@ module.exports = async (connection, query, options) => {
113
127
  return false;
114
128
  }
115
129
 
116
- const useEsearch = options.returnOptions && options.returnOptions.length > 0 && connection.capabilities.has('ESEARCH');
130
+ // ESEARCH is part of base IMAP4rev2
131
+ const useEsearch = options.returnOptions && options.returnOptions.length > 0 && hasCapability(connection, 'ESEARCH');
117
132
 
118
133
  if (useEsearch) {
119
134
  // Build RETURN (...) item list
@@ -142,14 +157,7 @@ module.exports = async (connection, query, options) => {
142
157
  untagged: {
143
158
  ESEARCH: async untagged => {
144
159
  if (!untagged || !untagged.attributes) return;
145
- // Strip leading (TAG "X") list and optional UID atom.
146
- // The IMAP parser represents parenthesized groups as
147
- // plain Arrays, not objects with type: 'LIST'.
148
- let attrs = untagged.attributes;
149
- let start = 0;
150
- if (attrs[start] && (Array.isArray(attrs[start]) || attrs[start].type === 'LIST')) start++;
151
- if (attrs[start] && typeof attrs[start].value === 'string' && attrs[start].value.toUpperCase() === 'UID') start++;
152
- esearchResult = parseEsearchResponse(attrs.slice(start));
160
+ esearchResult = parseEsearchResponse(stripEsearchPrefix(untagged.attributes));
153
161
  }
154
162
  }
155
163
  });
@@ -180,6 +188,73 @@ module.exports = async (connection, query, options) => {
180
188
  }
181
189
  });
182
190
  }
191
+ },
192
+
193
+ // IMAP4rev2 servers answer even a plain SEARCH with an untagged
194
+ // ESEARCH response (RFC 9051 deprecated the SEARCH response), so
195
+ // both forms are collected into the same result set
196
+ ESEARCH: async untagged => {
197
+ if (!untagged || !untagged.attributes) {
198
+ return;
199
+ }
200
+ let parsed = parseEsearchResponse(stripEsearchPrefix(untagged.attributes));
201
+ if (parsed.all) {
202
+ // Walk the compact sequence-set directly into the Set - the ALL
203
+ // result may cover the entire mailbox, so expanding it into an
204
+ // intermediate array first would double the peak memory use.
205
+ // The set comes from an untrusted server: endpoints must be
206
+ // valid nz-numbers ('Infinity' would otherwise loop forever)
207
+ // and the expansion stops at the mailbox EXISTS count - a
208
+ // conforming server cannot match more messages than exist, so
209
+ // a hostile range like 1:4294967295 cannot exhaust memory.
210
+ // A '*' means "largest number in use": that is exactly EXISTS
211
+ // for message sequence numbers, while server-sent UID sets may
212
+ // not contain '*' at all (RFC 9051 section 4.1.1), so UID
213
+ // parts with '*' are dropped
214
+ let existsCount = () => (connection.mailbox && connection.mailbox.exists) || 0;
215
+ let overBudget = () => results.size >= existsCount();
216
+ let resolveId = part => (part === '*' ? (options.uid ? 0 : existsCount()) : Number(part));
217
+ let truncated = false;
218
+ let discarded = false;
219
+ sequenceSetLoop: for (let part of parsed.all.split(',')) {
220
+ part = part.trim();
221
+ let colon = part.indexOf(':');
222
+ if (colon < 0) {
223
+ let value = resolveId(part);
224
+ if (!isValidSequenceValue(value)) {
225
+ discarded = true;
226
+ continue;
227
+ }
228
+ if (overBudget()) {
229
+ truncated = true;
230
+ break;
231
+ }
232
+ results.add(value);
233
+ continue;
234
+ }
235
+ let first = resolveId(part.substr(0, colon));
236
+ let second = resolveId(part.substr(colon + 1));
237
+ if (!isValidSequenceValue(first) || !isValidSequenceValue(second)) {
238
+ discarded = true;
239
+ continue;
240
+ }
241
+ for (let id = Math.min(first, second); id <= Math.max(first, second); id++) {
242
+ if (overBudget()) {
243
+ truncated = true;
244
+ break sequenceSetLoop;
245
+ }
246
+ results.add(id);
247
+ }
248
+ }
249
+ if (truncated || discarded) {
250
+ connection.log.warn({
251
+ msg: 'Invalid entries in the ESEARCH ALL result',
252
+ truncated,
253
+ discarded,
254
+ cid: connection.id
255
+ });
256
+ }
257
+ }
183
258
  }
184
259
  }
185
260
  });
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { encodePath, normalizePath } = require('../tools.js');
3
+ const { encodePath, normalizePath, buildStatusQueryAttributes, isRev2Active } = require('../tools.js');
4
4
 
5
5
  /**
6
6
  * Requests status information about a mailbox.
@@ -24,33 +24,18 @@ module.exports = async (connection, path, query) => {
24
24
  // otherwise use unquoted ATOM. Same approach as in SELECT.
25
25
  let attributes = [{ type: encodedPath.indexOf('&') >= 0 ? 'STRING' : 'ATOM', value: encodedPath }];
26
26
 
27
- // Build the list of STATUS data items the caller wants.
28
- // HIGHESTMODSEQ requires the CONDSTORE extension to be available.
29
- let queryAttributes = [];
30
- Object.keys(query || {}).forEach(key => {
31
- if (!query[key]) {
32
- return;
33
- }
27
+ // Build the list of STATUS data items the caller wants
28
+ let queryAttributes = buildStatusQueryAttributes(connection, query);
34
29
 
35
- switch (key.toUpperCase()) {
36
- case 'MESSAGES':
37
- case 'RECENT':
38
- case 'UIDNEXT':
39
- case 'UIDVALIDITY':
40
- case 'UNSEEN':
41
- queryAttributes.push({ type: 'ATOM', value: key.toUpperCase() });
42
- break;
43
-
44
- case 'HIGHESTMODSEQ':
45
- if (connection.capabilities.has('CONDSTORE')) {
46
- queryAttributes.push({ type: 'ATOM', value: key.toUpperCase() });
47
- }
48
- break;
49
- }
50
- });
30
+ // RECENT does not exist in IMAP4rev2 so it is never requested from a rev2
31
+ // session; its defined value there is always 0. Synthesizing it keeps the
32
+ // return shape identical to a rev1 session for the same query.
33
+ let syntheticRecent = query && query.recent && isRev2Active(connection);
51
34
 
52
35
  if (!queryAttributes.length) {
53
- return false;
36
+ // A query that only contained items unavailable on this session - the
37
+ // caller still gets a status object if every such item has a defined value
38
+ return syntheticRecent ? { path, recent: 0 } : false;
54
39
  }
55
40
 
56
41
  attributes.push(queryAttributes);
@@ -134,6 +119,9 @@ module.exports = async (connection, path, query) => {
134
119
  }
135
120
  });
136
121
  response.next();
122
+ if (syntheticRecent) {
123
+ map.recent = 0;
124
+ }
137
125
  return map;
138
126
  } catch (err) {
139
127
  // A NO response usually means the mailbox doesn't exist. Verify by
@@ -54,6 +54,8 @@ export interface ImapFlowOptions {
54
54
  disableBinary?: boolean;
55
55
  /** If true, do not enable supported extensions */
56
56
  disableAutoEnable?: boolean;
57
+ /** If true, do not enable IMAP4rev2 mode even if the server supports it */
58
+ disableIMAP4rev2?: boolean;
57
59
  /** How long to wait for the connection to be established. Defaults to 90 seconds */
58
60
  connectionTimeout?: number;
59
61
  /** How long to wait for the greeting. Defaults to 16 seconds */
@@ -103,7 +105,7 @@ export interface MailboxObject {
103
105
  specialUse?: string;
104
106
  /** True if mailbox was found from the output of LIST command */
105
107
  listed?: boolean;
106
- /** True if mailbox was found from the output of LSUB command */
108
+ /** True if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers */
107
109
  subscribed?: boolean;
108
110
  /** A Set of flags available to use in this mailbox. If it is not set or includes special flag "\*" then any flag can be used */
109
111
  permanentFlags?: Set<string>;
@@ -184,7 +186,7 @@ export interface ListResponse {
184
186
  specialUse?: string;
185
187
  /** True if mailbox was found from the output of LIST command */
186
188
  listed: boolean;
187
- /** True if mailbox was found from the output of LSUB command */
189
+ /** True if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers */
188
190
  subscribed: boolean;
189
191
  /** If statusQuery was used, then this value includes the status response */
190
192
  status?: StatusObject;
@@ -234,7 +236,7 @@ export interface ListTreeResponse {
234
236
  specialUse?: string;
235
237
  /** True if mailbox was found from the output of LIST command */
236
238
  listed?: boolean;
237
- /** True if mailbox was found from the output of LSUB command */
239
+ /** True if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers */
238
240
  subscribed?: boolean;
239
241
  /** If true then this mailbox can not be selected in the UI */
240
242
  disabled?: boolean;
package/lib/imap-flow.js CHANGED
@@ -35,7 +35,8 @@ const {
35
35
  normalizePath,
36
36
  expandRange,
37
37
  AuthenticationFailure,
38
- getColorFlags
38
+ getColorFlags,
39
+ hasCapability
39
40
  } = require('./tools');
40
41
 
41
42
  const imapCommands = require('./imap-commands.js');
@@ -69,7 +70,7 @@ const states = {
69
70
  * @property {Set<string>} flags list of flags for this mailbox
70
71
  * @property {String} [specialUse] one of special-use flags (if applicable): "\All", "\Archive", "\Drafts", "\Flagged", "\Junk", "\Sent", "\Trash". Additionally INBOX has non-standard "\Inbox" flag set
71
72
  * @property {Boolean} listed `true` if mailbox was found from the output of LIST command
72
- * @property {Boolean} subscribed `true` if mailbox was found from the output of LSUB command
73
+ * @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers
73
74
  * @property {Set<string>} permanentFlags A Set of flags available to use in this mailbox. If it is not set or includes special flag "\\\*" then any flag can be used.
74
75
  * @property {String} [mailboxId] unique mailbox ID if server has `OBJECTID` extension enabled
75
76
  * @property {BigInt} [highestModseq] latest known modseq value if server has CONDSTORE or XYMHIGHESTMODSEQ enabled
@@ -221,6 +222,11 @@ class ImapFlow extends EventEmitter {
221
222
  * @property {Boolean} [disableAutoEnable=false]
222
223
  * If `true`, do not automatically enable supported IMAP extensions.
223
224
  *
225
+ * @property {Boolean} [disableIMAP4rev2=false]
226
+ * If `true`, do not enable IMAP4rev2 mode even if the server supports it.
227
+ * Use as a targeted opt-out for servers with broken IMAP4rev2 implementations
228
+ * without losing the other auto-enabled extensions.
229
+ *
224
230
  * @property {Number} [connectionTimeout=90000]
225
231
  * Maximum time (in milliseconds) to wait for the connection to establish. Defaults to 90 seconds.
226
232
  *
@@ -397,6 +403,14 @@ class ImapFlow extends EventEmitter {
397
403
 
398
404
  this.disableBinary = !!this.options.disableBinary;
399
405
 
406
+ // Set when the server rejects a LIST RETURN option group, the auxiliary
407
+ // SPECIAL-USE/CHILDREN return options, or the LSUB command, so later
408
+ // listings on this connection skip what the server does not support
409
+ this.skipListSubscribedArg = false;
410
+ this.skipListStatusArgs = false;
411
+ this.skipListAuxArgs = false;
412
+ this.skipLsub = false;
413
+
400
414
  // Named error handler for proper cleanup. Certain error codes represent
401
415
  // expected socket/network issues (buffer exhaustion, connection reset, broken pipe,
402
416
  // timeout, unreachable host) that just need a silent connection close rather
@@ -568,7 +582,8 @@ class ImapFlow extends EventEmitter {
568
582
  // are stored in this.commandParts and sent after server "+" continuations.
569
583
  let compiled = await compiler(data, {
570
584
  asArray: true,
571
- literalMinus: this.capabilities.has('LITERAL-') || this.capabilities.has('LITERAL+')
585
+ // LITERAL- is part of base IMAP4rev2
586
+ literalMinus: hasCapability(this, 'LITERAL-') || this.capabilities.has('LITERAL+')
572
587
  });
573
588
  this.commandParts = compiled;
574
589
 
@@ -1053,13 +1068,29 @@ class ImapFlow extends EventEmitter {
1053
1068
  }
1054
1069
 
1055
1070
  if (!this.options.disableAutoEnable) {
1056
- // enable extensions if possible
1057
- await this.run('ENABLE', ['CONDSTORE', 'UTF8=ACCEPT'].concat(this.options.qresync ? 'QRESYNC' : []));
1071
+ await this.autoEnable();
1058
1072
  }
1059
1073
 
1060
1074
  this.usable = true;
1061
1075
  }
1062
1076
 
1077
+ // Enable extensions if possible. IMAP4rev2 must be enabled explicitly on
1078
+ // servers that advertise both rev1 and rev2 (RFC 9051 Appendix A); a single
1079
+ // ENABLE call is used so the enabled set is built in one round trip.
1080
+ async autoEnable() {
1081
+ let enableList = ['CONDSTORE', 'UTF8=ACCEPT'].concat(this.options.qresync ? 'QRESYNC' : []).concat(this.options.disableIMAP4rev2 ? [] : 'IMAP4rev2');
1082
+ let enableResult = await this.run('ENABLE', enableList);
1083
+ if (enableResult === false && enableList.includes('IMAP4rev2')) {
1084
+ // RFC 5161 requires servers to ignore unknown ENABLE arguments, but a
1085
+ // broken implementation may reject the whole command over IMAP4rev2 -
1086
+ // retry without it so CONDSTORE/QRESYNC are not lost as collateral
1087
+ await this.run(
1088
+ 'ENABLE',
1089
+ enableList.filter(extension => extension !== 'IMAP4rev2')
1090
+ );
1091
+ }
1092
+ }
1093
+
1063
1094
  async compress() {
1064
1095
  if (!(await this.run('COMPRESS'))) {
1065
1096
  return; // was not able to negotiate compression
@@ -2213,7 +2244,7 @@ class ImapFlow extends EventEmitter {
2213
2244
  * @property {Set<string>} flags a set of flags for this mailbox
2214
2245
  * @property {String} specialUse one of special-use flags (if applicable): "\All", "\Archive", "\Drafts", "\Flagged", "\Junk", "\Sent", "\Trash". Additionally INBOX has non-standard "\Inbox" flag set
2215
2246
  * @property {Boolean} listed `true` if mailbox was found from the output of LIST command
2216
- * @property {Boolean} subscribed `true` if mailbox was found from the output of LSUB command
2247
+ * @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers
2217
2248
  * @property {StatusObject} [status] If `statusQuery` was used, then this value includes the status response
2218
2249
  */
2219
2250
 
@@ -2261,9 +2292,10 @@ class ImapFlow extends EventEmitter {
2261
2292
  * @property {Set<string>} flags list of flags for this mailbox
2262
2293
  * @property {String} specialUse one of special-use flags (if applicable): "\All", "\Archive", "\Drafts", "\Flagged", "\Junk", "\Sent", "\Trash". Additionally INBOX has non-standard "\Inbox" flag set
2263
2294
  * @property {Boolean} listed `true` if mailbox was found from the output of LIST command
2264
- * @property {Boolean} subscribed `true` if mailbox was found from the output of LSUB command
2295
+ * @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers
2265
2296
  * @property {Boolean} disabled If `true` then this mailbox can not be selected in the UI
2266
2297
  * @property {ListTreeResponse[]} folders An array of subfolders
2298
+ * @property {StatusObject} [status] If `statusQuery` was used, then this value includes the status response
2267
2299
  */
2268
2300
 
2269
2301
  /**
@@ -2,7 +2,7 @@
2
2
 
3
3
  'use strict';
4
4
 
5
- const { formatDate, formatFlag, canUseFlag, isDate } = require('./tools.js');
5
+ const { formatDate, formatFlag, canUseFlag, isDate, isRev2Active } = require('./tools.js');
6
6
 
7
7
  /**
8
8
  * Sets a boolean flag in the IMAP search attributes.
@@ -186,10 +186,24 @@ module.exports.searchCompiler = (connection, query) => {
186
186
 
187
187
  // Simple boolean flags without UN- support
188
188
  case 'ALL':
189
+ if (params[term]) {
190
+ setBoolOpt(attributes, term, true);
191
+ }
192
+ break;
193
+
189
194
  case 'NEW':
190
195
  case 'OLD':
191
196
  case 'RECENT':
192
197
  if (params[term]) {
198
+ // The \Recent flag and the NEW/OLD/RECENT search keys were
199
+ // removed in IMAP4rev2 (RFC 9051) - a rev2 session would
200
+ // reject the whole search with a tagged BAD, so fail with a
201
+ // descriptive error instead
202
+ if (isRev2Active(connection)) {
203
+ let error = new Error(`The "${term.toLowerCase()}" search key does not exist in IMAP4rev2`);
204
+ error.code = 'MissingServerExtension';
205
+ throw error;
206
+ }
193
207
  setBoolOpt(attributes, term, true);
194
208
  }
195
209
  break;
package/lib/tools.js CHANGED
@@ -11,6 +11,34 @@ 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
+ // and the BINARY consumers have safe fallbacks of their own. The set mirrors the
23
+ // Appendix E list in full, including entries no call site consults yet, so any
24
+ // future capability check gets the rev2 folding for free.
25
+ const IMAP4REV2_FOLDED_CAPABILITIES = new Set([
26
+ 'ENABLE',
27
+ 'ESEARCH',
28
+ 'IDLE',
29
+ 'LIST-EXTENDED',
30
+ 'LIST-STATUS',
31
+ 'LITERAL-',
32
+ 'MOVE',
33
+ 'NAMESPACE',
34
+ 'SASL-IR',
35
+ 'SEARCHRES',
36
+ 'SPECIAL-USE',
37
+ 'STATUS=SIZE',
38
+ 'UIDPLUS',
39
+ 'UNSELECT'
40
+ ]);
41
+
14
42
  /**
15
43
  * Error subclass thrown when IMAP authentication fails.
16
44
  */
@@ -19,6 +47,80 @@ class AuthenticationFailure extends Error {
19
47
  }
20
48
 
21
49
  const tools = {
50
+ /**
51
+ * Checks whether IMAP4rev2 semantics are active for the connection: either the
52
+ * client enabled IMAP4rev2 explicitly, or the server is rev2-only (advertises
53
+ * IMAP4rev2 without IMAP4rev1), in which case rev2 is the base protocol without
54
+ * any ENABLE (RFC 9051 Appendix A). UTF-8 mailbox names apply in both cases.
55
+ *
56
+ * @param {Object} connection - IMAP connection instance
57
+ * @returns {Boolean} True if IMAP4rev2 semantics apply to this session
58
+ */
59
+ isRev2Active(connection) {
60
+ return connection.enabled.has('IMAP4REV2') || (connection.capabilities.has('IMAP4rev2') && !connection.capabilities.has('IMAP4rev1'));
61
+ },
62
+
63
+ /**
64
+ * Checks a capability, accounting for extensions that RFC 9051 folds into base
65
+ * IMAP4rev2. Falls back to the plain capability lookup on IMAP4rev1 sessions,
66
+ * so behavior against rev1 servers is unchanged.
67
+ *
68
+ * @param {Object} connection - IMAP connection instance
69
+ * @param {String} capability - Capability name, e.g. 'UIDPLUS'
70
+ * @returns {Boolean} True if the capability (or its rev2-folded equivalent) is available
71
+ */
72
+ hasCapability(connection, capability) {
73
+ if (connection.capabilities.has(capability)) {
74
+ return true;
75
+ }
76
+ return IMAP4REV2_FOLDED_CAPABILITIES.has(capability) && tools.isRev2Active(connection);
77
+ },
78
+
79
+ /**
80
+ * Builds the attribute list for a STATUS request - the standalone STATUS command
81
+ * or the LIST-STATUS return option - from a status query object. Items the current
82
+ * session cannot request (RECENT under IMAP4rev2, HIGHESTMODSEQ without CONDSTORE)
83
+ * are silently dropped.
84
+ *
85
+ * @param {Object} connection - IMAP connection instance
86
+ * @param {Object} statusQuery - Status data items to request, e.g. {messages: true}
87
+ * @returns {Object[]} Attribute token list for the command compiler
88
+ */
89
+ buildStatusQueryAttributes(connection, statusQuery) {
90
+ let attributes = [];
91
+
92
+ Object.keys(statusQuery || {}).forEach(key => {
93
+ if (!statusQuery[key]) {
94
+ return;
95
+ }
96
+
97
+ switch (key.toUpperCase()) {
98
+ case 'MESSAGES':
99
+ case 'UIDNEXT':
100
+ case 'UIDVALIDITY':
101
+ case 'UNSEEN':
102
+ attributes.push({ type: 'ATOM', value: key.toUpperCase() });
103
+ break;
104
+
105
+ case 'RECENT':
106
+ // RECENT was removed in IMAP4rev2 (RFC 9051) - requesting it from a
107
+ // rev2 session would get the whole STATUS request rejected
108
+ if (!tools.isRev2Active(connection)) {
109
+ attributes.push({ type: 'ATOM', value: key.toUpperCase() });
110
+ }
111
+ break;
112
+
113
+ case 'HIGHESTMODSEQ':
114
+ if (connection.capabilities.has('CONDSTORE')) {
115
+ attributes.push({ type: 'ATOM', value: key.toUpperCase() });
116
+ }
117
+ break;
118
+ }
119
+ });
120
+
121
+ return attributes;
122
+ },
123
+
22
124
  /**
23
125
  * Encodes a mailbox path to modified UTF-7 if the server does not support UTF8=ACCEPT.
24
126
  *
@@ -28,7 +130,7 @@ const tools = {
28
130
  */
29
131
  encodePath(connection, path) {
30
132
  path = (path || '').toString();
31
- if (!connection.enabled.has('UTF8=ACCEPT') && /[&\x00-\x08\x0b-\x0c\x0e-\x1f\u0080-\uffff]/.test(path)) {
133
+ if (!connection.enabled.has('UTF8=ACCEPT') && !tools.isRev2Active(connection) && /[&\x00-\x08\x0b-\x0c\x0e-\x1f\u0080-\uffff]/.test(path)) {
32
134
  try {
33
135
  path = iconv.encode(path, 'utf-7-imap').toString();
34
136
  } catch {
@@ -47,7 +149,7 @@ const tools = {
47
149
  */
48
150
  decodePath(connection, path) {
49
151
  path = (path || '').toString();
50
- if (!connection.enabled.has('UTF8=ACCEPT') && /[&]/.test(path)) {
152
+ if (!connection.enabled.has('UTF8=ACCEPT') && !tools.isRev2Active(connection) && /[&]/.test(path)) {
51
153
  try {
52
154
  path = iconv.decode(Buffer.from(path), 'utf-7-imap').toString();
53
155
  } catch {
@@ -120,6 +222,11 @@ const tools = {
120
222
  return;
121
223
  }
122
224
 
225
+ if (capability === 'IMAP4REV2') {
226
+ map.set('IMAP4rev2', true);
227
+ return;
228
+ }
229
+
123
230
  if (capability.startsWith('APPENDLIMIT=')) {
124
231
  let splitPos = capability.indexOf('=');
125
232
  let appendLimit = Number(capability.substr(splitPos + 1)) || 0;
@@ -222,7 +329,7 @@ const tools = {
222
329
  existing.path = folder.path;
223
330
  existing.subscribed = !!folder.subscribed;
224
331
  existing.listed = !!folder.listed;
225
- existing.status = !!folder.status;
332
+ existing.status = folder.status;
226
333
 
227
334
  if (folder.specialUse) {
228
335
  existing.specialUse = folder.specialUse;
@@ -242,7 +349,7 @@ const tools = {
242
349
  path: folder.path,
243
350
  subscribed: !!folder.subscribed,
244
351
  listed: !!folder.listed,
245
- status: !!folder.status
352
+ status: folder.status
246
353
  };
247
354
 
248
355
  if (folder.delimiter) {
@@ -1016,9 +1123,28 @@ const tools = {
1016
1123
  return !mailbox || !mailbox.permanentFlags || mailbox.permanentFlags.has('\\*') || mailbox.permanentFlags.has(flag);
1017
1124
  },
1018
1125
 
1126
+ /**
1127
+ * Checks that a value is a valid IMAP sequence number or UID: a non-zero
1128
+ * 32-bit unsigned integer (nz-number in the RFC 9051 grammar). Guards range
1129
+ * expansion against untrusted server input such as 'Infinity' or '0:*'.
1130
+ *
1131
+ * @param {Number} value - Value to check
1132
+ * @returns {Boolean} True if the value is a valid sequence number/UID
1133
+ */
1134
+ isValidSequenceValue(value) {
1135
+ return Number.isSafeInteger(value) && value > 0 && value <= 0xffffffff;
1136
+ },
1137
+
1019
1138
  /**
1020
1139
  * Expands an IMAP sequence range string (e.g. "1:3,5,7:9") into an array of numbers.
1021
1140
  *
1141
+ * Entries with endpoints that are not valid nz-numbers are skipped - the input
1142
+ * may come from an untrusted server, and 'Infinity' or similar garbage would
1143
+ * otherwise loop without bound. A single range is expanded to at most
1144
+ * EXPANDED_RANGE_LIMIT entries: legitimate responses never reach the limit
1145
+ * (the mailbox would need that many messages), while a hostile range like
1146
+ * 1:4294967295 is cut off instead of exhausting memory.
1147
+ *
1022
1148
  * @param {String} range - IMAP sequence range string
1023
1149
  * @returns {Number[]} Array of expanded sequence numbers
1024
1150
  */
@@ -1027,20 +1153,26 @@ const tools = {
1027
1153
  entry = entry.trim();
1028
1154
  let colon = entry.indexOf(':');
1029
1155
  if (colon < 0) {
1030
- return Number(entry) || 0;
1156
+ let value = Number(entry);
1157
+ return tools.isValidSequenceValue(value) ? value : [];
1158
+ }
1159
+ let first = Number(entry.substr(0, colon));
1160
+ let second = Number(entry.substr(colon + 1));
1161
+ if (!tools.isValidSequenceValue(first) || !tools.isValidSequenceValue(second)) {
1162
+ return [];
1031
1163
  }
1032
- let first = Number(entry.substr(0, colon)) || 0;
1033
- let second = Number(entry.substr(colon + 1)) || 0;
1034
1164
  if (first === second) {
1035
1165
  return first;
1036
1166
  }
1037
1167
  let list = [];
1038
1168
  if (first < second) {
1039
- for (let i = first; i <= second; i++) {
1169
+ let last = Math.min(second, first + EXPANDED_RANGE_LIMIT - 1);
1170
+ for (let i = first; i <= last; i++) {
1040
1171
  list.push(i);
1041
1172
  }
1042
1173
  } else {
1043
- for (let i = first; i >= second; i--) {
1174
+ let last = Math.max(second, first - EXPANDED_RANGE_LIMIT + 1);
1175
+ for (let i = first; i >= last; i--) {
1044
1176
  list.push(i);
1045
1177
  }
1046
1178
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.4.8",
3
+ "version": "1.4.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
+ "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: () => {} };