imapflow 1.4.7 → 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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +16 -0
- package/CLAUDE.md +10 -1
- package/Gruntfile.js +3 -1
- package/lib/commands/authenticate.js +13 -1
- package/lib/commands/enable.js +13 -4
- package/lib/commands/expunge.js +2 -2
- package/lib/commands/idle.js +6 -3
- package/lib/commands/list.js +237 -60
- package/lib/commands/move.js +2 -2
- package/lib/commands/namespace.js +3 -1
- package/lib/commands/search.js +88 -13
- package/lib/commands/status.js +13 -25
- package/lib/imap-flow.d.ts +5 -3
- package/lib/imap-flow.js +43 -9
- package/lib/search-compiler.js +15 -1
- package/lib/tools.js +141 -9
- package/package.json +7 -6
- package/test/commands-branches-test.js +11 -4
- package/test/commands-integration-test.js +1270 -124
- package/test/connection-edge-cases-test.js +2 -2
- package/test/connection-test.js +1 -1
- package/test/fixtures/test-tls.js +2 -2
- package/test/imap-flow-coverage-test.js +8 -1
- package/test/imap-flow-fetch-download-test.js +1 -4
- package/test/imap-flow-internals-test.js +2 -2
- package/test/imap-flow-methods-test.js +65 -6
- package/test/imap-parser-test.js +1 -2
- package/test/integration/README.md +40 -0
- package/test/integration/dovecot-test.conf +27 -0
- package/test/integration/rev2-live-test.js +242 -0
- package/test/integration/run-rev2-tests.sh +61 -0
- package/test/reliability-improvements-test.js +4 -1
- package/test/search-compiler-test.js +19 -0
- package/test/search-test.js +52 -54
- package/test/tools-test.js +134 -15
- package/.github/codeql/codeql-config.yml +0 -12
- package/.github/workflows/codeql.yml +0 -102
package/lib/commands/search.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/lib/commands/status.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
package/lib/imap-flow.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
*
|
|
@@ -277,12 +283,14 @@ class ImapFlow extends EventEmitter {
|
|
|
277
283
|
*/
|
|
278
284
|
this.secureConnection = !!this.options.secure;
|
|
279
285
|
|
|
280
|
-
|
|
286
|
+
// 993 is IMAPS, 143 is IMAP over cleartext/STARTTLS. The non-secure default used to be 110,
|
|
287
|
+
// which is POP3 - a client created without an explicit port could never connect.
|
|
288
|
+
this.port = Number(this.options.port) || (this.secureConnection ? 993 : 143);
|
|
281
289
|
this.host = this.options.host || 'localhost';
|
|
282
290
|
this.servername = this.options.servername ? this.options.servername : !net.isIP(this.host) ? this.host : false;
|
|
283
291
|
|
|
284
292
|
if (typeof this.options.secure === 'undefined' && this.port === 993) {
|
|
285
|
-
// if secure option is not set but port is
|
|
293
|
+
// if secure option is not set but port is 993, then default to secure
|
|
286
294
|
this.secureConnection = true;
|
|
287
295
|
}
|
|
288
296
|
|
|
@@ -395,6 +403,14 @@ class ImapFlow extends EventEmitter {
|
|
|
395
403
|
|
|
396
404
|
this.disableBinary = !!this.options.disableBinary;
|
|
397
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
|
+
|
|
398
414
|
// Named error handler for proper cleanup. Certain error codes represent
|
|
399
415
|
// expected socket/network issues (buffer exhaustion, connection reset, broken pipe,
|
|
400
416
|
// timeout, unreachable host) that just need a silent connection close rather
|
|
@@ -566,7 +582,8 @@ class ImapFlow extends EventEmitter {
|
|
|
566
582
|
// are stored in this.commandParts and sent after server "+" continuations.
|
|
567
583
|
let compiled = await compiler(data, {
|
|
568
584
|
asArray: true,
|
|
569
|
-
|
|
585
|
+
// LITERAL- is part of base IMAP4rev2
|
|
586
|
+
literalMinus: hasCapability(this, 'LITERAL-') || this.capabilities.has('LITERAL+')
|
|
570
587
|
});
|
|
571
588
|
this.commandParts = compiled;
|
|
572
589
|
|
|
@@ -1051,13 +1068,29 @@ class ImapFlow extends EventEmitter {
|
|
|
1051
1068
|
}
|
|
1052
1069
|
|
|
1053
1070
|
if (!this.options.disableAutoEnable) {
|
|
1054
|
-
|
|
1055
|
-
await this.run('ENABLE', ['CONDSTORE', 'UTF8=ACCEPT'].concat(this.options.qresync ? 'QRESYNC' : []));
|
|
1071
|
+
await this.autoEnable();
|
|
1056
1072
|
}
|
|
1057
1073
|
|
|
1058
1074
|
this.usable = true;
|
|
1059
1075
|
}
|
|
1060
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
|
+
|
|
1061
1094
|
async compress() {
|
|
1062
1095
|
if (!(await this.run('COMPRESS'))) {
|
|
1063
1096
|
return; // was not able to negotiate compression
|
|
@@ -2211,7 +2244,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2211
2244
|
* @property {Set<string>} flags a set of flags for this mailbox
|
|
2212
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
|
|
2213
2246
|
* @property {Boolean} listed `true` if mailbox was found from the output of LIST command
|
|
2214
|
-
* @property {Boolean} subscribed `true` if mailbox
|
|
2247
|
+
* @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers
|
|
2215
2248
|
* @property {StatusObject} [status] If `statusQuery` was used, then this value includes the status response
|
|
2216
2249
|
*/
|
|
2217
2250
|
|
|
@@ -2259,9 +2292,10 @@ class ImapFlow extends EventEmitter {
|
|
|
2259
2292
|
* @property {Set<string>} flags list of flags for this mailbox
|
|
2260
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
|
|
2261
2294
|
* @property {Boolean} listed `true` if mailbox was found from the output of LIST command
|
|
2262
|
-
* @property {Boolean} subscribed `true` if mailbox
|
|
2295
|
+
* @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers
|
|
2263
2296
|
* @property {Boolean} disabled If `true` then this mailbox can not be selected in the UI
|
|
2264
2297
|
* @property {ListTreeResponse[]} folders An array of subfolders
|
|
2298
|
+
* @property {StatusObject} [status] If `statusQuery` was used, then this value includes the status response
|
|
2265
2299
|
*/
|
|
2266
2300
|
|
|
2267
2301
|
/**
|
package/lib/search-compiler.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 ."
|
|
@@ -28,18 +29,18 @@
|
|
|
28
29
|
"homepage": "https://imapflow.com/",
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@eslint/js": "10.0.1",
|
|
31
|
-
"@types/node": "26.1.
|
|
32
|
-
"c8": "
|
|
33
|
-
"eslint": "10.
|
|
32
|
+
"@types/node": "26.1.1",
|
|
33
|
+
"c8": "12.0.0",
|
|
34
|
+
"eslint": "10.7.0",
|
|
34
35
|
"eslint-config-nodemailer": "1.2.0",
|
|
35
36
|
"eslint-config-prettier": "10.1.8",
|
|
36
37
|
"grunt": "1.6.2",
|
|
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.
|
|
41
|
+
"prettier": "3.9.6",
|
|
41
42
|
"proxyquire": "^2.1.3",
|
|
42
|
-
"typescript": "
|
|
43
|
+
"typescript": "7.0.2"
|
|
43
44
|
},
|
|
44
45
|
"dependencies": {
|
|
45
46
|
"@zone-eu/mailsplit": "5.4.14",
|