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.
- package/.github/workflows/test.yml +20 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/CLAUDE.md +12 -5
- package/Gruntfile.js +3 -1
- package/lib/commands/authenticate.js +8 -3
- package/lib/commands/enable.js +13 -4
- package/lib/commands/expunge.js +2 -2
- package/lib/commands/fetch.js +18 -14
- package/lib/commands/idle.js +6 -3
- package/lib/commands/list.js +241 -61
- 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 +19 -26
- package/lib/handler/imap-compiler.js +12 -9
- package/lib/handler/token-parser.js +7 -0
- package/lib/imap-flow.d.ts +19 -3
- package/lib/imap-flow.js +58 -9
- package/lib/search-compiler.js +15 -1
- package/lib/tools.js +173 -9
- package/package.json +3 -2
- package/test/commands-branches-test.js +11 -4
- package/test/commands-integration-test.js +1528 -108
- package/test/connection-edge-cases-test.js +4 -40
- package/test/fixtures/test-tls.js +2 -2
- package/test/handler-branches-test.js +4 -3
- package/test/imap-compiler-test.js +85 -0
- package/test/imap-flow-coverage-test.js +8 -1
- package/test/imap-flow-fetch-download-test.js +57 -4
- package/test/imap-flow-internals-test.js +2 -2
- package/test/imap-flow-methods-test.js +65 -6
- package/test/imap-flow-secure-test.js +25 -11
- package/test/imap-flow-server-test.js +80 -0
- package/test/imap-parser-test.js +113 -3
- package/test/imap-stream-test.js +46 -0
- package/test/integration/README.md +52 -0
- package/test/integration/dovecot-test.conf +27 -0
- package/test/integration/rev2-live-test.js +367 -0
- package/test/integration/run-rev2-tests.sh +75 -0
- package/test/reliability-improvements-test.js +4 -1
- package/test/search-compiler-test.js +36 -0
- package/test/search-test.js +52 -54
- 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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
427
|
-
//
|
|
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: [
|
|
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: () => {} };
|