imapflow 1.6.5 → 1.6.6
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 +7 -0
- package/lib/commands/append.js +26 -4
- package/lib/commands/copyuid-parser.js +4 -2
- package/lib/commands/expunge.js +5 -2
- package/lib/commands/fetch.js +9 -3
- package/lib/commands/list.js +18 -38
- package/lib/commands/namespace.js +2 -2
- package/lib/commands/quota.js +10 -2
- package/lib/commands/search.js +54 -14
- package/lib/commands/select.js +81 -70
- package/lib/commands/status-fields.js +68 -0
- package/lib/commands/status.js +23 -61
- package/lib/handler/imap-stream.js +56 -2
- package/lib/handler/limits.js +16 -4
- package/lib/imap-flow.d.ts +22 -2
- package/lib/imap-flow.js +131 -61
- package/lib/jp-decoder.js +30 -5
- package/lib/limited-passthrough.js +19 -1
- package/lib/tools.js +190 -39
- package/package.json +4 -4
- package/test/commands-branches-test.js +4 -0
- package/test/commands-integration-test.js +683 -0
- package/test/copyuid-parser-test.js +20 -0
- package/test/imap-flow-coverage-test.js +4 -2
- package/test/imap-flow-fetch-download-test.js +26 -0
- package/test/imap-flow-methods-test.js +92 -0
- package/test/imap-stream-edge-cases-test.js +136 -0
- package/test/jp-decoder-test.js +57 -0
- package/test/limited-passthrough-test.js +24 -0
- package/test/parser-limits-test.js +18 -0
- package/test/reliability-improvements-test.js +3 -3
- package/test/timer-policy-test.js +27 -1
- package/test/tools-test.js +151 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.6.6](https://github.com/postalsys/imapflow/compare/v1.6.5...v1.6.6) (2026-08-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* harden parsing of untrusted server response values ([1560424](https://github.com/postalsys/imapflow/commit/15604240b5fe5f7ec57514db17f41123140b53fe))
|
|
9
|
+
|
|
3
10
|
## [1.6.5](https://github.com/postalsys/imapflow/compare/v1.6.4...v1.6.5) (2026-07-29)
|
|
4
11
|
|
|
5
12
|
|
package/lib/commands/append.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const {
|
|
4
|
+
formatFlag,
|
|
5
|
+
canUseFlag,
|
|
6
|
+
formatDateTime,
|
|
7
|
+
normalizePath,
|
|
8
|
+
encodePath,
|
|
9
|
+
comparePaths,
|
|
10
|
+
enhanceCommandError,
|
|
11
|
+
parseBigIntValue,
|
|
12
|
+
parseUintValue,
|
|
13
|
+
MAX_UINT32_DIGITS
|
|
14
|
+
} = require('../tools.js');
|
|
4
15
|
|
|
5
16
|
/**
|
|
6
17
|
* Appends a message to a mailbox.
|
|
@@ -79,7 +90,15 @@ module.exports = async (connection, destination, content, flags, idate) => {
|
|
|
79
90
|
// Handler for untagged EXISTS: captures the new message count which gives
|
|
80
91
|
// us the sequence number of the appended message (it's the latest message).
|
|
81
92
|
const handleExistsUpdate = untagged => {
|
|
82
|
-
|
|
93
|
+
// The count is written into the live mailbox state below, so it has to clear the
|
|
94
|
+
// same bar untaggedExists() applies: a digit run long enough to coerce to Infinity
|
|
95
|
+
// would make resolveRange('*') compile to the literal string "Infinity" and break
|
|
96
|
+
// every range-based command until the next SELECT.
|
|
97
|
+
let seq = parseUintValue(untagged.command, MAX_UINT32_DIGITS);
|
|
98
|
+
if (seq === false) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
map.seq = seq;
|
|
83
102
|
|
|
84
103
|
// Update the connection's mailbox state and emit 'exists' event if the
|
|
85
104
|
// count changed (notifies listeners about the new message).
|
|
@@ -109,8 +128,11 @@ module.exports = async (connection, destination, content, flags, idate) => {
|
|
|
109
128
|
if (section && section.length) {
|
|
110
129
|
let responseCode = section[0] && typeof section[0].value === 'string' ? section[0].value : '';
|
|
111
130
|
if (responseCode.toUpperCase() === 'APPENDUID') {
|
|
112
|
-
|
|
113
|
-
|
|
131
|
+
// Bounded digit runs only: isNaN() also passes '1e5', which BigInt() rejects
|
|
132
|
+
// with a throw - and this catch rethrows, so the append would reject after the
|
|
133
|
+
// message was already stored and a retrying caller would duplicate it.
|
|
134
|
+
let uidValidity = parseBigIntValue(section[1] && section[1].value, MAX_UINT32_DIGITS);
|
|
135
|
+
let uid = parseUintValue(section[2] && section[2].value, MAX_UINT32_DIGITS);
|
|
114
136
|
if (uidValidity !== false) {
|
|
115
137
|
map.uidValidity = uidValidity;
|
|
116
138
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { expandRange } = require('../tools.js');
|
|
3
|
+
const { expandRange, parseBigIntValue } = require('../tools.js');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Parses COPYUID response code from an IMAP response (RFC 4315).
|
|
@@ -18,7 +18,9 @@ function parseCopyUid(response, map) {
|
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Only a bounded pure digit string is accepted: isNaN() also passes values like "1e5" or
|
|
22
|
+
// "Infinity", which BigInt() then rejects with a throw that loses the uidMap.
|
|
23
|
+
let uidValidity = parseBigIntValue(section[1] && section[1].value);
|
|
22
24
|
if (uidValidity !== false) {
|
|
23
25
|
map.uidValidity = uidValidity;
|
|
24
26
|
}
|
package/lib/commands/expunge.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { enhanceCommandError, hasCapability } = require('../tools.js');
|
|
3
|
+
const { enhanceCommandError, hasCapability, parseBigIntValue } = require('../tools.js');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Deletes specified messages by flagging them as Deleted and expunging.
|
|
@@ -41,7 +41,10 @@ module.exports = async (connection, range, options) => {
|
|
|
41
41
|
let section = response.response.attributes && response.response.attributes[0] && response.response.attributes[0].section;
|
|
42
42
|
let responseCode = section && section.length && section[0] && typeof section[0].value === 'string' ? section[0].value : '';
|
|
43
43
|
if (responseCode.toUpperCase() === 'HIGHESTMODSEQ') {
|
|
44
|
-
|
|
44
|
+
// Bounded digit runs only: isNaN() also passes '1e5', which BigInt() rejects with
|
|
45
|
+
// a throw that the catch below would swallow, making messageDelete() report false
|
|
46
|
+
// even though the server expunged the messages.
|
|
47
|
+
let highestModseq = parseBigIntValue(section[1] && section[1].value);
|
|
45
48
|
if (highestModseq && (!connection.mailbox.highestModseq || highestModseq > connection.mailbox.highestModseq)) {
|
|
46
49
|
connection.mailbox.highestModseq = highestModseq;
|
|
47
50
|
}
|
package/lib/commands/fetch.js
CHANGED
|
@@ -235,7 +235,9 @@ module.exports = async (connection, range, query, options) => {
|
|
|
235
235
|
// If server provides a throttleReset hint, use that if longer.
|
|
236
236
|
const backoffDelay = Math.min(baseDelay * Math.pow(2, retryCount), 30000); // Cap at 30 seconds
|
|
237
237
|
|
|
238
|
-
// Use throttle reset time if provided and longer than backoff
|
|
238
|
+
// Use throttle reset time if provided and longer than backoff. The hint is
|
|
239
|
+
// server-controlled, so the wait goes through connection.throttleWait(), which caps
|
|
240
|
+
// it and keeps the timer tracked and abortable.
|
|
239
241
|
const delay = err.throttleReset && err.throttleReset > backoffDelay ? err.throttleReset : backoffDelay;
|
|
240
242
|
|
|
241
243
|
connection.log.warn({
|
|
@@ -248,8 +250,12 @@ module.exports = async (connection, range, query, options) => {
|
|
|
248
250
|
delayMs: delay
|
|
249
251
|
});
|
|
250
252
|
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
+
// An aborted wait means the client was closed, so give up rather than reissuing
|
|
254
|
+
// the FETCH on a connection that is already gone.
|
|
255
|
+
let aborted = await connection.throttleWait(delay);
|
|
256
|
+
if (aborted) {
|
|
257
|
+
throw connection.createNoConnectionError(connection.byeReason);
|
|
258
|
+
}
|
|
253
259
|
|
|
254
260
|
retryCount++;
|
|
255
261
|
continue;
|
package/lib/commands/list.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const {
|
|
4
|
+
decodePath,
|
|
5
|
+
encodePath,
|
|
6
|
+
normalizePath,
|
|
7
|
+
enhanceCommandError,
|
|
8
|
+
hasCapability,
|
|
9
|
+
isRev2Active,
|
|
10
|
+
buildStatusQueryAttributes,
|
|
11
|
+
getStringList
|
|
12
|
+
} = require('../tools.js');
|
|
13
|
+
const { parseStatusList } = require('./status-fields.js');
|
|
4
14
|
const { specialUse } = require('../special-use');
|
|
5
15
|
|
|
6
16
|
/**
|
|
@@ -133,7 +143,9 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
133
143
|
// User-provided hints map mailbox paths to special-use types (e.g., {sent: "Sent Items"}).
|
|
134
144
|
// These override server-reported flags and name-based guesses. Converted to a
|
|
135
145
|
// path-keyed lookup: { "Sent Items" => "\\Sent" }
|
|
136
|
-
|
|
146
|
+
// Keyed by server-supplied mailbox paths further down, so a null prototype keeps a
|
|
147
|
+
// path like "constructor" from resolving to an inherited member on lookup
|
|
148
|
+
let specialUseHints = Object.create(null);
|
|
137
149
|
if (options.specialUseHints && typeof options.specialUseHints === 'object') {
|
|
138
150
|
for (let type of Object.keys(options.specialUseHints)) {
|
|
139
151
|
if (
|
|
@@ -170,7 +182,7 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
170
182
|
// Decode from modified UTF-7 wire format and normalize the path
|
|
171
183
|
path: normalizePath(connection, decodePath(connection, (untagged.attributes[2] && untagged.attributes[2].value) || '')),
|
|
172
184
|
pathAsListed: (untagged.attributes[2] && untagged.attributes[2].value) || '',
|
|
173
|
-
flags: new Set(untagged.attributes[0]
|
|
185
|
+
flags: new Set(getStringList(untagged.attributes[0])),
|
|
174
186
|
delimiter: untagged.attributes[1] && untagged.attributes[1].value,
|
|
175
187
|
listed: true
|
|
176
188
|
};
|
|
@@ -236,41 +248,9 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
236
248
|
return;
|
|
237
249
|
}
|
|
238
250
|
|
|
239
|
-
const STATUS_FIELD_MAP = {
|
|
240
|
-
MESSAGES: { key: 'messages', parser: Number },
|
|
241
|
-
RECENT: { key: 'recent', parser: Number },
|
|
242
|
-
UIDNEXT: { key: 'uidNext', parser: Number },
|
|
243
|
-
UIDVALIDITY: { key: 'uidValidity', parser: BigInt },
|
|
244
|
-
UNSEEN: { key: 'unseen', parser: Number },
|
|
245
|
-
HIGHESTMODSEQ: { key: 'highestModseq', parser: BigInt },
|
|
246
|
-
// IMAP4rev2 additions (RFC 9051): mailbox size and \Deleted count
|
|
247
|
-
SIZE: { key: 'size', parser: Number },
|
|
248
|
-
DELETED: { key: 'deleted', parser: Number }
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
let key;
|
|
252
251
|
let map = { path: statusPath };
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (i % 2 === 0) {
|
|
256
|
-
key = entry && typeof entry.value === 'string' ? entry.value : false;
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
if (!key || !entry || typeof entry.value !== 'string') {
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const fieldConfig = STATUS_FIELD_MAP[key.toUpperCase()];
|
|
264
|
-
if (!fieldConfig) {
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const value = !isNaN(entry.value) ? fieldConfig.parser(entry.value) : false;
|
|
269
|
-
if (value === false) {
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
map[fieldConfig.key] = value;
|
|
252
|
+
parseStatusList(statusList, (key, value) => {
|
|
253
|
+
map[key] = value;
|
|
274
254
|
});
|
|
275
255
|
|
|
276
256
|
statusMap.set(statusPath, map);
|
|
@@ -455,7 +435,7 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
455
435
|
let entry = {
|
|
456
436
|
path: normalizePath(connection, decodePath(connection, (untagged.attributes[2] && untagged.attributes[2].value) || '')),
|
|
457
437
|
pathAsListed: (untagged.attributes[2] && untagged.attributes[2].value) || '',
|
|
458
|
-
flags: new Set(untagged.attributes[0]
|
|
438
|
+
flags: new Set(getStringList(untagged.attributes[0])),
|
|
459
439
|
delimiter: untagged.attributes[1] && untagged.attributes[1].value,
|
|
460
440
|
subscribed: true
|
|
461
441
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { hasCapability } = require('../tools.js');
|
|
3
|
+
const { hasCapability, getStringList } = require('../tools.js');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Requests NAMESPACE info from the server.
|
|
@@ -95,7 +95,7 @@ async function getListPrefix(connection) {
|
|
|
95
95
|
return;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
map.flags = new Set(untagged.attributes[0]
|
|
98
|
+
map.flags = new Set(getStringList(untagged.attributes[0]));
|
|
99
99
|
map.delimiter = untagged.attributes[1] && untagged.attributes[1].value;
|
|
100
100
|
map.prefix = (untagged.attributes[2] && untagged.attributes[2].value) || '';
|
|
101
101
|
if (map.delimiter && map.prefix.charAt(0) === map.delimiter) {
|
package/lib/commands/quota.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
|
|
3
|
+
const { encodePath, normalizePath, enhanceCommandError, parseUintValue, isUnsafeKey } = require('../tools.js');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Requests quota information for a mailbox.
|
|
@@ -47,11 +47,19 @@ module.exports = async (connection, path) => {
|
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// isNaN() also passes '1e5', ' 12 ' and 'Infinity', none of which is a usable octet count
|
|
51
|
+
let value = parseUintValue(attribute && attribute.value);
|
|
51
52
|
if (value === false) {
|
|
52
53
|
return;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// Resource names are server-controlled. This object is returned to the caller, so it
|
|
57
|
+
// keeps a normal prototype and unsafe names are dropped instead; the fixed fields
|
|
58
|
+
// must keep their values too.
|
|
59
|
+
if (isUnsafeKey(key) || key === 'path' || key === 'quotaroot') {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
if (!map[key]) {
|
|
56
64
|
map[key] = {};
|
|
57
65
|
}
|
package/lib/commands/search.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const {
|
|
4
|
+
enhanceCommandError,
|
|
5
|
+
hasCapability,
|
|
6
|
+
isValidSequenceValue,
|
|
7
|
+
parseBigIntValue,
|
|
8
|
+
parseUintValue,
|
|
9
|
+
MAX_UINT32_DIGITS,
|
|
10
|
+
EXPANDED_RANGE_LIMIT
|
|
11
|
+
} = require('../tools.js');
|
|
4
12
|
const { searchCompiler } = require('../search-compiler.js');
|
|
5
13
|
|
|
6
14
|
/**
|
|
@@ -48,27 +56,29 @@ function parseEsearchResponse(attrs) {
|
|
|
48
56
|
continue;
|
|
49
57
|
}
|
|
50
58
|
switch (key) {
|
|
59
|
+
// COUNT is a plain message count; MIN and MAX are sequence numbers or UIDs. All
|
|
60
|
+
// three are bounded decimal runs - isNaN() would also admit '1e400' (Infinity)
|
|
51
61
|
case 'COUNT': {
|
|
52
|
-
const n =
|
|
53
|
-
if (
|
|
62
|
+
const n = parseUintValue(attrs[++i]?.value, MAX_UINT32_DIGITS);
|
|
63
|
+
if (n !== false) result.count = n;
|
|
54
64
|
break;
|
|
55
65
|
}
|
|
56
66
|
case 'MIN': {
|
|
57
|
-
const n =
|
|
58
|
-
if (
|
|
67
|
+
const n = parseUintValue(attrs[++i]?.value, MAX_UINT32_DIGITS);
|
|
68
|
+
if (n !== false) result.min = n;
|
|
59
69
|
break;
|
|
60
70
|
}
|
|
61
71
|
case 'MAX': {
|
|
62
|
-
const n =
|
|
63
|
-
if (
|
|
72
|
+
const n = parseUintValue(attrs[++i]?.value, MAX_UINT32_DIGITS);
|
|
73
|
+
if (n !== false) result.max = n;
|
|
64
74
|
break;
|
|
65
75
|
}
|
|
66
76
|
case 'MODSEQ': {
|
|
67
77
|
// RFC 7162 section 3.1.5: present when the SEARCH used a MODSEQ
|
|
68
78
|
// criterion on a CONDSTORE-enabled session. BigInt because
|
|
69
79
|
// mod-sequence values are unsigned 63-bit
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
80
|
+
const modseq = parseBigIntValue(attrs[++i]?.value);
|
|
81
|
+
if (modseq !== false) result.modseq = modseq;
|
|
72
82
|
break;
|
|
73
83
|
}
|
|
74
84
|
case 'ALL': {
|
|
@@ -192,11 +202,37 @@ module.exports = async (connection, query, options) => {
|
|
|
192
202
|
untagged: {
|
|
193
203
|
SEARCH: async untagged => {
|
|
194
204
|
if (untagged && untagged.attributes && untagged.attributes.length) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
205
|
+
let truncated = false;
|
|
206
|
+
let discarded = false;
|
|
207
|
+
for (let attribute of untagged.attributes) {
|
|
208
|
+
// The result set is server-controlled and accumulated across
|
|
209
|
+
// responses, so stop at the same absolute ceiling expandRange()
|
|
210
|
+
// uses - a server streaming SEARCH responses could otherwise
|
|
211
|
+
// grow the set until the process runs out of memory
|
|
212
|
+
/* c8 ignore next 4 */ // reaching the ceiling needs 2^24 accumulated results, which no unit test can produce in reasonable time
|
|
213
|
+
if (results.size >= EXPANDED_RANGE_LIMIT) {
|
|
214
|
+
truncated = true;
|
|
215
|
+
break;
|
|
198
216
|
}
|
|
199
|
-
|
|
217
|
+
// Same nz-number check the ESEARCH branch below applies. isNaN()
|
|
218
|
+
// is not enough: it passes '1e400' (Infinity), '-3' and '2.5', and
|
|
219
|
+
// a single one of those makes the sequence set compiled from this
|
|
220
|
+
// result set invalid, failing the caller's whole follow-up command
|
|
221
|
+
let value = attribute && typeof attribute.value === 'string' ? Number(attribute.value) : NaN;
|
|
222
|
+
if (!isValidSequenceValue(value)) {
|
|
223
|
+
discarded = true;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
results.add(value);
|
|
227
|
+
}
|
|
228
|
+
if (truncated || discarded) {
|
|
229
|
+
connection.log.warn({
|
|
230
|
+
msg: 'Invalid entries in the SEARCH result',
|
|
231
|
+
truncated,
|
|
232
|
+
discarded,
|
|
233
|
+
cid: connection.id
|
|
234
|
+
});
|
|
235
|
+
}
|
|
200
236
|
}
|
|
201
237
|
},
|
|
202
238
|
|
|
@@ -222,7 +258,11 @@ module.exports = async (connection, query, options) => {
|
|
|
222
258
|
// not contain '*' at all (RFC 9051 section 4.1.1), so UID
|
|
223
259
|
// parts with '*' are dropped
|
|
224
260
|
let existsCount = () => (connection.mailbox && connection.mailbox.exists) || 0;
|
|
225
|
-
|
|
261
|
+
// The mailbox EXISTS count is itself server-supplied and can be
|
|
262
|
+
// absurdly large, so the budget is additionally capped at the same
|
|
263
|
+
// absolute ceiling expandRange() uses - a hostile server cannot
|
|
264
|
+
// bypass it by inflating EXISTS first
|
|
265
|
+
let overBudget = () => results.size >= existsCount() || results.size >= EXPANDED_RANGE_LIMIT;
|
|
226
266
|
let resolveId = part => (part === '*' ? (options.uid ? 0 : existsCount()) : Number(part));
|
|
227
267
|
let truncated = false;
|
|
228
268
|
let discarded = false;
|
package/lib/commands/select.js
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
|
|
3
|
+
const { encodePath, normalizePath, enhanceCommandError, parseBigIntValue, parseUintValue, getStringList, MAX_UINT32_DIGITS } = require('../tools.js');
|
|
4
|
+
|
|
5
|
+
// Response codes carrying a value that SELECT/EXAMINE may write to the mailbox object, keyed by
|
|
6
|
+
// the lowercased code, mapped to the fixed public property name and the parser for the value.
|
|
7
|
+
// Every parser returns false for a value it cannot use, and the field is then left unset.
|
|
8
|
+
//
|
|
9
|
+
// This is an allowlist on purpose. The mailbox object is API surface: without one, an arbitrary
|
|
10
|
+
// server-sent [KEY value] code could overwrite `path` (defeating the DELETE/RENAME guards that
|
|
11
|
+
// compare paths) or `flags`, and a parenthesized value under "__proto__" would replace the
|
|
12
|
+
// object's prototype. The lookup itself is on a null-prototype object for the same reason - the
|
|
13
|
+
// key is lowercased, so "constructor" would otherwise resolve to an inherited member.
|
|
14
|
+
const VALUED_RESPONSE_CODES = Object.assign(Object.create(null), {
|
|
15
|
+
// CONDSTORE (RFC 7162): highest mod-sequence value for the mailbox, used for incremental
|
|
16
|
+
// sync. Stored as a BigInt since modseq values can exceed Number.MAX_SAFE_INTEGER.
|
|
17
|
+
//
|
|
18
|
+
// A value that is not a bounded digit run is dropped rather than stored raw: every consumer
|
|
19
|
+
// compares highestModseq relationally, and a relational compare against a non-numeric string
|
|
20
|
+
// is false in both directions, so the value could never advance and delta sync would stop.
|
|
21
|
+
highestmodseq: { key: 'highestModseq', parse: value => parseBigIntValue(value) },
|
|
22
|
+
|
|
23
|
+
// Unique identifier validity. If this changes between sessions, all previously cached UIDs
|
|
24
|
+
// are invalid and the client must re-sync from scratch. Nominally 32-bit, but stored as a
|
|
25
|
+
// BigInt precisely so a server that exceeds that still round-trips, hence the wider bound.
|
|
26
|
+
uidvalidity: { key: 'uidValidity', parse: value => parseBigIntValue(value) },
|
|
27
|
+
|
|
28
|
+
// The next UID to be assigned in this mailbox, useful for detecting new arrivals. A huge
|
|
29
|
+
// digit run would coerce to Infinity and corrupt every later UID range computation.
|
|
30
|
+
uidnext: { key: 'uidNext', parse: value => parseUintValue(value, MAX_UINT32_DIGITS) },
|
|
31
|
+
|
|
32
|
+
// Sequence number of the first unseen message (RFC 3501 section 7.1). Not a count of unseen
|
|
33
|
+
// messages - use mailboxStatus() with {unseen: true} for that.
|
|
34
|
+
unseen: { key: 'unseen', parse: value => parseUintValue(value, MAX_UINT32_DIGITS) },
|
|
35
|
+
|
|
36
|
+
// APPENDLIMIT (RFC 7889): largest message size in octets the server accepts for APPEND into
|
|
37
|
+
// this mailbox. Spelled all lowercase, unlike the camelCase fields around it, because that is
|
|
38
|
+
// the name this object has always exposed.
|
|
39
|
+
appendlimit: { key: 'appendlimit', parse: value => parseUintValue(value) },
|
|
40
|
+
|
|
41
|
+
// OBJECTID (RFC 8474): server-assigned mailbox identifier that survives renames. Sent as a
|
|
42
|
+
// parenthesized list, but servers in the wild send it bare too.
|
|
43
|
+
mailboxid: {
|
|
44
|
+
key: 'mailboxId',
|
|
45
|
+
parse: value => (Array.isArray(value) ? value.length > 0 && value[0] : typeof value === 'string' && value)
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Flags the client may change permanently on messages in this mailbox, including \* if the
|
|
49
|
+
// server allows custom flags. Only the parenthesized form carries flags, and a malformed
|
|
50
|
+
// value must leave permanentFlags unset rather than set an empty Set: canUseFlag() reads
|
|
51
|
+
// unset as permissive and empty as deny-all, so an empty Set would turn every later flag
|
|
52
|
+
// update into a silent no-op for the rest of the session.
|
|
53
|
+
permanentflags: { key: 'permanentFlags', parse: value => Array.isArray(value) && new Set(value) }
|
|
54
|
+
});
|
|
4
55
|
|
|
5
56
|
/**
|
|
6
57
|
* Selects or examines a mailbox, making it the current mailbox for subsequent operations.
|
|
@@ -92,78 +143,33 @@ module.exports = async (connection, path, options) => {
|
|
|
92
143
|
}
|
|
93
144
|
let section = !untagged.attributes[0].value && untagged.attributes[0].section;
|
|
94
145
|
// Handle response codes with a key-value pair (section has 2+ elements)
|
|
95
|
-
if (section && section.length > 1 && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
146
|
+
if (section && section.length > 1 && section[0] && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
96
147
|
let key = section[0].value.toLowerCase();
|
|
97
148
|
let value;
|
|
98
149
|
|
|
99
|
-
// Value can be a single string or a list of strings (e.g., PERMANENTFLAGS)
|
|
100
|
-
|
|
150
|
+
// Value can be a single string or a list of strings (e.g., PERMANENTFLAGS).
|
|
151
|
+
// section[1] can be a parsed NIL (null), and so can any element inside a
|
|
152
|
+
// parenthesized list, so both levels need the guard
|
|
153
|
+
if (section[1] && typeof section[1].value === 'string') {
|
|
101
154
|
value = section[1].value;
|
|
102
155
|
} else if (Array.isArray(section[1])) {
|
|
103
|
-
value = section[1]
|
|
156
|
+
value = getStringList(section[1]);
|
|
104
157
|
}
|
|
105
158
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
key = 'highestModseq';
|
|
113
|
-
if (/^[0-9]+$/.test(value)) {
|
|
114
|
-
value = BigInt(value);
|
|
115
|
-
}
|
|
116
|
-
break;
|
|
117
|
-
|
|
118
|
-
// OBJECTID (RFC 8474): server-assigned unique mailbox identifier.
|
|
119
|
-
// Unlike path, this ID survives renames. Value comes as a
|
|
120
|
-
// parenthesized list, so extract the first (only) element.
|
|
121
|
-
case 'mailboxid':
|
|
122
|
-
key = 'mailboxId';
|
|
123
|
-
if (Array.isArray(value) && value.length) {
|
|
124
|
-
value = value[0];
|
|
125
|
-
}
|
|
126
|
-
break;
|
|
127
|
-
|
|
128
|
-
// Flags that the client can change permanently on messages in
|
|
129
|
-
// this mailbox. Includes \* if the server allows custom flags.
|
|
130
|
-
case 'permanentflags':
|
|
131
|
-
key = 'permanentFlags';
|
|
132
|
-
value = new Set(value);
|
|
133
|
-
break;
|
|
134
|
-
|
|
135
|
-
// The next UID that will be assigned to a new message in this
|
|
136
|
-
// mailbox. Useful for detecting new arrivals.
|
|
137
|
-
case 'uidnext':
|
|
138
|
-
key = 'uidNext';
|
|
139
|
-
value = Number(value);
|
|
140
|
-
break;
|
|
141
|
-
|
|
142
|
-
// Unique identifier validity value. If this changes between
|
|
143
|
-
// sessions, all previously cached UIDs are invalid and the
|
|
144
|
-
// client must re-sync from scratch.
|
|
145
|
-
case 'uidvalidity':
|
|
146
|
-
key = 'uidValidity';
|
|
147
|
-
if (/^[0-9]+$/.test(value)) {
|
|
148
|
-
value = BigInt(value);
|
|
149
|
-
}
|
|
150
|
-
break;
|
|
159
|
+
let field = VALUED_RESPONSE_CODES[key];
|
|
160
|
+
if (field) {
|
|
161
|
+
let parsed = field.parse(value);
|
|
162
|
+
if (parsed !== false) {
|
|
163
|
+
map[field.key] = parsed;
|
|
164
|
+
}
|
|
151
165
|
}
|
|
152
|
-
|
|
153
|
-
map[key] = value;
|
|
154
166
|
}
|
|
155
167
|
|
|
156
|
-
// Handle response codes with only a keyword (no value), e.g., [NOMODSEQ]
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
// CONDSTORE/QRESYNC features are unavailable for this mailbox.
|
|
162
|
-
case 'nomodseq':
|
|
163
|
-
key = 'noModseq';
|
|
164
|
-
map[key] = true;
|
|
165
|
-
break;
|
|
166
|
-
}
|
|
168
|
+
// Handle response codes with only a keyword (no value), e.g., [NOMODSEQ].
|
|
169
|
+
// NOMODSEQ means the mailbox does not support mod-sequences, so the
|
|
170
|
+
// CONDSTORE/QRESYNC features are unavailable for it.
|
|
171
|
+
if (section && section.length === 1 && section[0] && section[0].type === 'ATOM' && section[0].value?.toUpperCase() === 'NOMODSEQ') {
|
|
172
|
+
map.noModseq = true;
|
|
167
173
|
}
|
|
168
174
|
},
|
|
169
175
|
|
|
@@ -173,15 +179,16 @@ module.exports = async (connection, path, options) => {
|
|
|
173
179
|
if (!untagged.attributes || !untagged.attributes.length || !Array.isArray(untagged.attributes[0])) {
|
|
174
180
|
return;
|
|
175
181
|
}
|
|
176
|
-
|
|
177
|
-
map.flags = new Set(flags);
|
|
182
|
+
map.flags = new Set(getStringList(untagged.attributes[0]));
|
|
178
183
|
},
|
|
179
184
|
|
|
180
185
|
// Untagged EXISTS response: "* <count> EXISTS" tells us the total number
|
|
181
186
|
// of messages in the mailbox. The count is in the command field (numeric prefix).
|
|
182
187
|
EXISTS: async untagged => {
|
|
183
|
-
|
|
184
|
-
|
|
188
|
+
// Not a usable count: anything but a bounded digit run. A long digit run
|
|
189
|
+
// coerces to Infinity, which would corrupt every later range computation
|
|
190
|
+
let num = parseUintValue(untagged.command, MAX_UINT32_DIGITS);
|
|
191
|
+
if (num === false) {
|
|
185
192
|
return false;
|
|
186
193
|
}
|
|
187
194
|
|
|
@@ -204,9 +211,13 @@ module.exports = async (connection, path, options) => {
|
|
|
204
211
|
});
|
|
205
212
|
|
|
206
213
|
// The tagged OK response to SELECT/EXAMINE includes [READ-ONLY] or [READ-WRITE]
|
|
207
|
-
// in its response code, indicating the access mode the server granted.
|
|
208
|
-
|
|
209
|
-
|
|
214
|
+
// in its response code, indicating the access mode the server granted. A tagged OK
|
|
215
|
+
// with no resp-text has no `attributes` property at all, and unlike the untagged
|
|
216
|
+
// handlers above this runs in the command body, where a throw would tear down the
|
|
217
|
+
// mailbox state the server has actually selected.
|
|
218
|
+
let okAttributes = (response.response && response.response.attributes) || [];
|
|
219
|
+
let section = okAttributes[0] && !okAttributes[0].value && okAttributes[0].section;
|
|
220
|
+
if (section && section.length && section[0] && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
210
221
|
map.readOnly = section[0].value.toUpperCase() === 'READ-ONLY';
|
|
211
222
|
}
|
|
212
223
|
|