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/commands/list.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { decodePath, encodePath, normalizePath } = require('../tools.js');
|
|
3
|
+
const { decodePath, encodePath, normalizePath, enhanceCommandError, hasCapability, isRev2Active, buildStatusQueryAttributes } = require('../tools.js');
|
|
4
4
|
const { specialUse } = require('../special-use');
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -29,57 +29,78 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
29
29
|
|
|
30
30
|
// Prefer XLIST (legacy Gmail extension) only if SPECIAL-USE (RFC 6154) is unavailable.
|
|
31
31
|
// Both provide special-use flags, but SPECIAL-USE is the standardized approach.
|
|
32
|
-
|
|
32
|
+
// SPECIAL-USE is checked with rev2 folding - a rev2 session implies SPECIAL-USE,
|
|
33
|
+
// so LIST is preferred even if a rev2 server also advertised legacy XLIST.
|
|
34
|
+
let listCommand = connection.capabilities.has('XLIST') && !hasCapability(connection, 'SPECIAL-USE') ? 'XLIST' : 'LIST';
|
|
33
35
|
|
|
34
|
-
let response;
|
|
35
36
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
let
|
|
42
|
-
let
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
37
|
+
// Accumulators filled by the untagged LIST/STATUS handlers below. statusMap
|
|
38
|
+
// caches STATUS responses received inline via LIST-STATUS extension, keyed by
|
|
39
|
+
// normalized mailbox path (avoids separate STATUS commands per mailbox), and
|
|
40
|
+
// specialUseMatches tracks candidate mailboxes for each special-use type.
|
|
41
|
+
// (Re)initialized at the start of each retry stage of the main listing.
|
|
42
|
+
let entries;
|
|
43
|
+
let statusMap;
|
|
44
|
+
let specialUseMatches;
|
|
45
|
+
|
|
46
|
+
// STATUS data items to request (MESSAGES, UIDNEXT, etc.)
|
|
47
|
+
let statusQueryAttributes = buildStatusQueryAttributes(connection, options.statusQuery);
|
|
48
|
+
|
|
49
|
+
// Extended LIST syntax (RETURN options) is understood by servers advertising
|
|
50
|
+
// LIST-EXTENDED (RFC 5258) or IMAP4rev2 (RFC 9051). Deliberately keyed on the
|
|
51
|
+
// advertisement alone (not hasCapability/isRev2Active): the staged retry below
|
|
52
|
+
// handles servers that advertise but reject RETURN options, so the wider gate
|
|
53
|
+
// is safe for anything it covers, while gates without a retry ladder stay
|
|
54
|
+
// conservative.
|
|
55
|
+
let supportsExtendedList = connection.capabilities.has('LIST-EXTENDED') || connection.capabilities.has('IMAP4rev2');
|
|
56
|
+
|
|
57
|
+
// RETURN options for the LIST command. Servers occasionally advertise the
|
|
58
|
+
// extensions but still reject RETURN options - the staged retry below then
|
|
59
|
+
// re-runs the LIST with fewer options and latches a skip flag for the option
|
|
60
|
+
// group the server proved to reject, keeping later listings efficient.
|
|
61
|
+
|
|
62
|
+
// LIST-STATUS (RFC 5819, folded into base IMAP4rev2): request STATUS data
|
|
63
|
+
// inline with LIST, avoiding a separate STATUS command for each mailbox.
|
|
64
|
+
let canRequestStatus =
|
|
65
|
+
listCommand === 'LIST' && !connection.skipListStatusArgs && hasCapability(connection, 'LIST-STATUS') && !!statusQueryAttributes.length;
|
|
66
|
+
|
|
67
|
+
// RETURN (SUBSCRIBED): request subscription state inline instead of a separate
|
|
68
|
+
// LSUB command. IMAP4rev2 removed LSUB entirely, and some servers (e.g.
|
|
69
|
+
// Exchange in IMAP4rev2 mode) reject it with BAD even while still advertising
|
|
70
|
+
// IMAP4rev1.
|
|
71
|
+
let canRequestSubscribed = listCommand === 'LIST' && !options.listOnly && !connection.skipListSubscribedArg && supportsExtendedList;
|
|
72
|
+
|
|
73
|
+
// Auxiliary RETURN options (SPECIAL-USE/CHILDREN) that ride along with the
|
|
74
|
+
// STATUS/SUBSCRIBED option groups. When RETURN options are present, servers
|
|
75
|
+
// may report only what was explicitly requested (verified against Dovecot
|
|
76
|
+
// 2.4: special-use and child attributes disappear from such responses), so
|
|
77
|
+
// request everything a plain LIST would have provided.
|
|
78
|
+
let auxArgsAvailable = hasCapability(connection, 'SPECIAL-USE') || connection.capabilities.has('CHILDREN') || supportsExtendedList;
|
|
79
|
+
let stageHasAuxArgs = stage => (stage.status || stage.subscribed) && stage.aux !== false && !connection.skipListAuxArgs && auxArgsAvailable;
|
|
80
|
+
|
|
81
|
+
// Builds the RETURN (...) argument list for one retry stage
|
|
82
|
+
let buildListArgs = stage => {
|
|
83
|
+
let args = [];
|
|
84
|
+
if (stage.status) {
|
|
85
|
+
args.push({ type: 'ATOM', value: 'STATUS' }, statusQueryAttributes);
|
|
86
|
+
}
|
|
87
|
+
if (stageHasAuxArgs(stage)) {
|
|
88
|
+
if (hasCapability(connection, 'SPECIAL-USE')) {
|
|
89
|
+
args.push({ type: 'ATOM', value: 'SPECIAL-USE' });
|
|
49
90
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
case 'MESSAGES':
|
|
53
|
-
case 'RECENT':
|
|
54
|
-
case 'UIDNEXT':
|
|
55
|
-
case 'UIDVALIDITY':
|
|
56
|
-
case 'UNSEEN':
|
|
57
|
-
statusQueryAttributes.push({ type: 'ATOM', value: key.toUpperCase() });
|
|
58
|
-
break;
|
|
59
|
-
|
|
60
|
-
case 'HIGHESTMODSEQ':
|
|
61
|
-
if (connection.capabilities.has('CONDSTORE')) {
|
|
62
|
-
statusQueryAttributes.push({ type: 'ATOM', value: key.toUpperCase() });
|
|
63
|
-
}
|
|
64
|
-
break;
|
|
91
|
+
if (connection.capabilities.has('CHILDREN') || supportsExtendedList) {
|
|
92
|
+
args.push({ type: 'ATOM', value: 'CHILDREN' });
|
|
65
93
|
}
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// LIST-STATUS (RFC 5819): allows requesting STATUS data inline with LIST,
|
|
70
|
-
// avoiding a separate STATUS command for each mailbox. Adds RETURN (STATUS (...))
|
|
71
|
-
// and optionally SPECIAL-USE to the LIST command arguments.
|
|
72
|
-
if (listCommand === 'LIST' && connection.capabilities.has('LIST-STATUS') && statusQueryAttributes.length) {
|
|
73
|
-
returnArgs.push({ type: 'ATOM', value: 'STATUS' }, statusQueryAttributes);
|
|
74
|
-
if (connection.capabilities.has('SPECIAL-USE')) {
|
|
75
|
-
returnArgs.push({ type: 'ATOM', value: 'SPECIAL-USE' });
|
|
76
94
|
}
|
|
77
|
-
|
|
95
|
+
if (stage.subscribed) {
|
|
96
|
+
args.push({ type: 'ATOM', value: 'SUBSCRIBED' });
|
|
97
|
+
}
|
|
98
|
+
return args;
|
|
99
|
+
};
|
|
78
100
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
let specialUseMatches = {};
|
|
101
|
+
// Multiple mailboxes may claim the same special-use type (e.g., \\Sent) via
|
|
102
|
+
// different sources (user hint, server extension, name match). After listing,
|
|
103
|
+
// the best match wins.
|
|
83
104
|
let addSpecialUseMatch = (entry, type, source) => {
|
|
84
105
|
if (!specialUseMatches[type]) {
|
|
85
106
|
specialUseMatches[type] = [];
|
|
@@ -90,10 +111,17 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
90
111
|
// RFC 5258: the \NonExistent attribute implies \Noselect. Some servers only
|
|
91
112
|
// return \NonExistent for phantom folders, so add \Noselect as well to keep
|
|
92
113
|
// the flags consistent for consumers that only check \Noselect.
|
|
114
|
+
// RETURN (SUBSCRIBED) - and some LSUB implementations - report subscription
|
|
115
|
+
// state as a \Subscribed attribute. Move it to the subscribed property so the
|
|
116
|
+
// output shape is the same however the state was delivered.
|
|
93
117
|
let normalizeFlags = entry => {
|
|
94
118
|
if (entry.flags.has('\\NonExistent')) {
|
|
95
119
|
entry.flags.add('\\Noselect');
|
|
96
120
|
}
|
|
121
|
+
if (entry.flags.has('\\Subscribed')) {
|
|
122
|
+
entry.flags.delete('\\Subscribed');
|
|
123
|
+
entry.subscribed = true;
|
|
124
|
+
}
|
|
97
125
|
};
|
|
98
126
|
|
|
99
127
|
// User-provided hints map mailbox paths to special-use types (e.g., {sent: "Sent Items"}).
|
|
@@ -116,14 +144,14 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
116
144
|
// Executes a LIST (or XLIST) command and collects mailbox entries.
|
|
117
145
|
// Called once for the main listing and optionally again for INBOX if a
|
|
118
146
|
// namespace prefix was used (INBOX may live outside the namespace).
|
|
119
|
-
let runList = async (reference, mailbox) => {
|
|
147
|
+
let runList = async (reference, mailbox, returnArgs) => {
|
|
120
148
|
const cmdArgs = [encodePath(connection, reference), encodePath(connection, mailbox)];
|
|
121
149
|
|
|
122
150
|
if (returnArgs.length) {
|
|
123
151
|
cmdArgs.push({ type: 'ATOM', value: 'RETURN' }, returnArgs);
|
|
124
152
|
}
|
|
125
153
|
|
|
126
|
-
response = await connection.exec(listCommand, cmdArgs, {
|
|
154
|
+
let response = await connection.exec(listCommand, cmdArgs, {
|
|
127
155
|
untagged: {
|
|
128
156
|
// Each untagged LIST response: * LIST (<flags>) "<delimiter>" "<mailbox name>"
|
|
129
157
|
// attributes[0] = flags array, attributes[1] = delimiter, attributes[2] = mailbox name
|
|
@@ -159,8 +187,9 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
159
187
|
}
|
|
160
188
|
|
|
161
189
|
// Name-based INBOX detection: any mailbox named "INBOX" (case-insensitive)
|
|
162
|
-
// is the inbox per RFC 3501.
|
|
163
|
-
|
|
190
|
+
// is the inbox per RFC 3501. Phantom \NonExistent entries (subscribed
|
|
191
|
+
// leftovers of deleted mailboxes) must not claim the slot by name.
|
|
192
|
+
if (entry.path.toUpperCase() === 'INBOX' && !entry.flags.has('\\NonExistent')) {
|
|
164
193
|
addSpecialUseMatch(entry, '\\Inbox', 'name');
|
|
165
194
|
}
|
|
166
195
|
|
|
@@ -177,11 +206,14 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
177
206
|
// Try to detect special-use from server flags or well-known names
|
|
178
207
|
// (e.g., "Sent", "Drafts", "Junk", "Trash")
|
|
179
208
|
let { flag: specialUseFlag, source: flagSource } = specialUse(
|
|
180
|
-
connection.capabilities.has('XLIST') || connection
|
|
209
|
+
connection.capabilities.has('XLIST') || hasCapability(connection, 'SPECIAL-USE'),
|
|
181
210
|
entry
|
|
182
211
|
);
|
|
183
212
|
|
|
184
|
-
|
|
213
|
+
// A name-based guess for a \NonExistent phantom entry could win the
|
|
214
|
+
// special-use slot over the real folder - only server-provided flags
|
|
215
|
+
// are trusted for nonexistent entries
|
|
216
|
+
if (specialUseFlag && (flagSource !== 'name' || !entry.flags.has('\\NonExistent'))) {
|
|
185
217
|
addSpecialUseMatch(entry, specialUseFlag, flagSource);
|
|
186
218
|
}
|
|
187
219
|
|
|
@@ -203,7 +235,10 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
203
235
|
UIDNEXT: { key: 'uidNext', parser: Number },
|
|
204
236
|
UIDVALIDITY: { key: 'uidValidity', parser: BigInt },
|
|
205
237
|
UNSEEN: { key: 'unseen', parser: Number },
|
|
206
|
-
HIGHESTMODSEQ: { key: 'highestModseq', parser: BigInt }
|
|
238
|
+
HIGHESTMODSEQ: { key: 'highestModseq', parser: BigInt },
|
|
239
|
+
// IMAP4rev2 additions (RFC 9051): mailbox size and \Deleted count
|
|
240
|
+
SIZE: { key: 'size', parser: Number },
|
|
241
|
+
DELETED: { key: 'deleted', parser: Number }
|
|
207
242
|
};
|
|
208
243
|
|
|
209
244
|
let key;
|
|
@@ -239,7 +274,87 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
239
274
|
};
|
|
240
275
|
|
|
241
276
|
let normalizedReference = normalizePath(connection, reference || '');
|
|
242
|
-
|
|
277
|
+
let normalizedMailbox = normalizePath(connection, mailbox || '', true);
|
|
278
|
+
|
|
279
|
+
// Retry stages for the main listing: start with all applicable RETURN options
|
|
280
|
+
// and drop one option group per retry. Consecutive stages differ by exactly one
|
|
281
|
+
// group, so a success right after a rejection identifies the offending group
|
|
282
|
+
// and only that group's skip flag is latched for the rest of the connection.
|
|
283
|
+
// When a stage carrying the auxiliary SPECIAL-USE/CHILDREN options is rejected,
|
|
284
|
+
// a copy of the same stage without them is inserted first (once per listing),
|
|
285
|
+
// so an auxiliary-only rejection does not get a whole option group blamed.
|
|
286
|
+
let stages = [];
|
|
287
|
+
if (canRequestStatus && canRequestSubscribed) {
|
|
288
|
+
stages.push({ status: true, subscribed: true });
|
|
289
|
+
}
|
|
290
|
+
if (canRequestStatus) {
|
|
291
|
+
stages.push({ status: true, subscribed: false });
|
|
292
|
+
} else if (canRequestSubscribed) {
|
|
293
|
+
stages.push({ status: false, subscribed: true });
|
|
294
|
+
}
|
|
295
|
+
stages.push({ status: false, subscribed: false });
|
|
296
|
+
|
|
297
|
+
// A tagged BAD is how servers reject unrecognized RETURN options (RFC 9051
|
|
298
|
+
// section 6.3.9). A tagged NO is an operational failure, and throttling
|
|
299
|
+
// errors (code ETHROTTLE) also surface with a BAD status - neither says
|
|
300
|
+
// anything about the RETURN options, so they propagate to the caller.
|
|
301
|
+
let isRejectedCommand = err => err.responseStatus === 'BAD' && err.code !== 'ETHROTTLE';
|
|
302
|
+
|
|
303
|
+
// Stage of the successful attempt - reused by the INBOX fixup and the LSUB
|
|
304
|
+
// decision below
|
|
305
|
+
let successStage = null;
|
|
306
|
+
|
|
307
|
+
let lastRejectedStage = null;
|
|
308
|
+
let auxRetryInserted = false;
|
|
309
|
+
for (let i = 0; i < stages.length; i++) {
|
|
310
|
+
let stage = stages[i];
|
|
311
|
+
let stageArgs = buildListArgs(stage);
|
|
312
|
+
// Discard partial results from a rejected attempt
|
|
313
|
+
entries = [];
|
|
314
|
+
statusMap = new Map();
|
|
315
|
+
specialUseMatches = {};
|
|
316
|
+
try {
|
|
317
|
+
await runList(normalizedReference, normalizedMailbox, stageArgs);
|
|
318
|
+
if (lastRejectedStage) {
|
|
319
|
+
// Latch only the option group that was present in the rejected
|
|
320
|
+
// attempt but missing from this successful one - that group is
|
|
321
|
+
// proven to be what the server rejects. An unproven group (e.g.
|
|
322
|
+
// SUBSCRIBED when both groups were dropped one by one) is decided
|
|
323
|
+
// by the reduced stage list of the next listing.
|
|
324
|
+
if (lastRejectedStage.subscribed && !stage.subscribed) {
|
|
325
|
+
connection.skipListSubscribedArg = true;
|
|
326
|
+
}
|
|
327
|
+
if (lastRejectedStage.status && !stage.status) {
|
|
328
|
+
connection.skipListStatusArgs = true;
|
|
329
|
+
}
|
|
330
|
+
if (
|
|
331
|
+
stageHasAuxArgs(lastRejectedStage) &&
|
|
332
|
+
stage.aux === false &&
|
|
333
|
+
lastRejectedStage.status === stage.status &&
|
|
334
|
+
lastRejectedStage.subscribed === stage.subscribed
|
|
335
|
+
) {
|
|
336
|
+
// Same option groups, only the auxiliary args dropped - the
|
|
337
|
+
// auxiliaries are proven to be what the server rejects
|
|
338
|
+
connection.skipListAuxArgs = true;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
successStage = stage;
|
|
342
|
+
break;
|
|
343
|
+
} catch (err) {
|
|
344
|
+
if (i === stages.length - 1 || !isRejectedCommand(err)) {
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
lastRejectedStage = stage;
|
|
348
|
+
if (!auxRetryInserted && stageHasAuxArgs(stage)) {
|
|
349
|
+
// The rejection may be about the auxiliary options rather than the
|
|
350
|
+
// option groups - try the same groups without the auxiliaries before
|
|
351
|
+
// dropping a group
|
|
352
|
+
stages.splice(i + 1, 0, { ...stage, aux: false });
|
|
353
|
+
auxRetryInserted = true;
|
|
354
|
+
}
|
|
355
|
+
connection.log.warn({ msg: 'LIST RETURN options rejected, retrying with reduced options', err, cid: connection.id });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
243
358
|
|
|
244
359
|
if (options.listOnly) {
|
|
245
360
|
return entries;
|
|
@@ -248,17 +363,55 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
248
363
|
// When listing with a namespace prefix (e.g., "INBOX."), INBOX itself may
|
|
249
364
|
// not appear in results. Run a separate LIST for INBOX to ensure it's included.
|
|
250
365
|
if (normalizedReference && !specialUseMatches['\\Inbox']) {
|
|
251
|
-
|
|
366
|
+
let returnArgs = buildListArgs(successStage);
|
|
367
|
+
// Snapshot the accumulator sizes: a rejected fixup attempt may have
|
|
368
|
+
// streamed partial untagged responses before its tagged BAD, and those
|
|
369
|
+
// must be discarded before the retry or INBOX would be listed twice -
|
|
370
|
+
// while the main run's results must be kept
|
|
371
|
+
let entryCountBefore = entries.length;
|
|
372
|
+
let specialUseCountsBefore = {};
|
|
373
|
+
for (let type of Object.keys(specialUseMatches)) {
|
|
374
|
+
specialUseCountsBefore[type] = specialUseMatches[type].length;
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
await runList('', 'INBOX', returnArgs);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
// The main listing just succeeded with the same RETURN options, so a
|
|
380
|
+
// rejection here says nothing about the options themselves - retry
|
|
381
|
+
// this one call plain without latching any skip flags. Accepted edge:
|
|
382
|
+
// if the main run filled statusMap, INBOX ends up without inline
|
|
383
|
+
// status data.
|
|
384
|
+
if (!returnArgs.length || !isRejectedCommand(err)) {
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
entries.length = entryCountBefore;
|
|
388
|
+
for (let type of Object.keys(specialUseMatches)) {
|
|
389
|
+
if (!(type in specialUseCountsBefore)) {
|
|
390
|
+
delete specialUseMatches[type];
|
|
391
|
+
} else {
|
|
392
|
+
specialUseMatches[type].length = specialUseCountsBefore[type];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
connection.log.warn({ msg: 'INBOX LIST with RETURN options failed, retrying plain', err, cid: connection.id });
|
|
396
|
+
await runList('', 'INBOX', []);
|
|
397
|
+
}
|
|
252
398
|
}
|
|
253
399
|
|
|
254
400
|
// Attach STATUS data to each selectable mailbox. If LIST-STATUS was used,
|
|
255
401
|
// data is already in statusMap; otherwise, fall back to individual STATUS commands.
|
|
256
402
|
if (options.statusQuery) {
|
|
403
|
+
// RECENT does not exist in IMAP4rev2, so it is never requested from a rev2
|
|
404
|
+
// session - its defined value there is always 0 (the STATUS command module
|
|
405
|
+
// applies the same rule on the per-mailbox fallback path)
|
|
406
|
+
let syntheticRecent = options.statusQuery.recent && isRev2Active(connection);
|
|
257
407
|
for (let entry of entries) {
|
|
258
408
|
// \\Noselect and \\NonExistent mailboxes cannot hold messages
|
|
259
409
|
if (!entry.flags.has('\\Noselect') && !entry.flags.has('\\NonExistent')) {
|
|
260
410
|
if (statusMap.has(entry.path)) {
|
|
261
411
|
entry.status = statusMap.get(entry.path);
|
|
412
|
+
if (syntheticRecent) {
|
|
413
|
+
entry.status.recent = 0;
|
|
414
|
+
}
|
|
262
415
|
} else if (!statusMap.size) {
|
|
263
416
|
// Server didn't support LIST-STATUS; fall back to per-mailbox STATUS
|
|
264
417
|
try {
|
|
@@ -275,10 +428,8 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
275
428
|
// We merge subscription info into the entries already collected from LIST.
|
|
276
429
|
// Subscribed-only mailboxes that weren't in LIST are intentionally ignored
|
|
277
430
|
// (they may be phantom entries from old subscriptions to deleted mailboxes).
|
|
278
|
-
|
|
279
|
-
'LSUB',
|
|
280
|
-
[encodePath(connection, normalizePath(connection, reference || '')), encodePath(connection, normalizePath(connection, mailbox || '', true))],
|
|
281
|
-
{
|
|
431
|
+
let runLsub = async () => {
|
|
432
|
+
let response = await connection.exec('LSUB', [encodePath(connection, normalizedReference), encodePath(connection, normalizedMailbox)], {
|
|
282
433
|
untagged: {
|
|
283
434
|
LSUB: async untagged => {
|
|
284
435
|
if (!untagged.attributes || !untagged.attributes.length) {
|
|
@@ -316,9 +467,35 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
316
467
|
// Non-listed subscribed folders are intentionally ignored
|
|
317
468
|
}
|
|
318
469
|
}
|
|
470
|
+
});
|
|
471
|
+
response.next();
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// Skipped when RETURN (SUBSCRIBED) already provided subscription state or when
|
|
475
|
+
// this connection's server already rejected LSUB once. Safety net: if the
|
|
476
|
+
// extended LIST was accepted but not a single mailbox came back subscribed on a
|
|
477
|
+
// non-rev2 session, assume the server silently ignored RETURN (SUBSCRIBED) and
|
|
478
|
+
// fall back to LSUB anyway (a rev2 session has no LSUB to fall back to, and an
|
|
479
|
+
// account without any subscriptions legitimately looks the same).
|
|
480
|
+
let needsLsub = !successStage.subscribed || (!isRev2Active(connection) && !entries.some(entry => entry.subscribed));
|
|
481
|
+
if (needsLsub && !connection.skipLsub) {
|
|
482
|
+
try {
|
|
483
|
+
await runLsub();
|
|
484
|
+
} catch (err) {
|
|
485
|
+
if (isRejectedCommand(err)) {
|
|
486
|
+
// Tagged BAD: the server does not recognize the command (IMAP4rev2
|
|
487
|
+
// removed LSUB) - skip LSUB for the rest of this connection
|
|
488
|
+
connection.skipLsub = true;
|
|
489
|
+
} else if (err.responseStatus !== 'NO' || err.code === 'ETHROTTLE') {
|
|
490
|
+
// Transport failures and throttling: rethrow, every follow-up
|
|
491
|
+
// command would fail too or the caller needs to back off
|
|
492
|
+
throw err;
|
|
493
|
+
}
|
|
494
|
+
// Subscription state is auxiliary - keep the LIST results usable. A
|
|
495
|
+
// tagged NO is treated as transient, so the next listing tries again.
|
|
496
|
+
connection.log.warn({ msg: 'Failed to request subscription info', err, cid: connection.id });
|
|
319
497
|
}
|
|
320
|
-
|
|
321
|
-
response.next();
|
|
498
|
+
}
|
|
322
499
|
|
|
323
500
|
// Resolve special-use conflicts: for each type, pick the best candidate
|
|
324
501
|
// based on source priority (user > extension > name), then alphabetically.
|
|
@@ -372,6 +549,9 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
372
549
|
return a.path.localeCompare(b.path);
|
|
373
550
|
});
|
|
374
551
|
} catch (err) {
|
|
552
|
+
// Rewrite the parsed err.response into the response text and set
|
|
553
|
+
// serverResponseCode, same as the other command modules
|
|
554
|
+
await enhanceCommandError(err);
|
|
375
555
|
connection.log.warn({ msg: 'Failed to list folders', err, cid: connection.id });
|
|
376
556
|
throw err;
|
|
377
557
|
}
|
package/lib/commands/move.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { normalizePath, encodePath, enhanceCommandError } = require('../tools.js');
|
|
3
|
+
const { normalizePath, encodePath, enhanceCommandError, hasCapability } = require('../tools.js');
|
|
4
4
|
const { parseCopyUid } = require('./copyuid-parser.js');
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -31,7 +31,7 @@ module.exports = async (connection, range, destination, options) => {
|
|
|
31
31
|
|
|
32
32
|
// Fallback for servers without the MOVE extension (RFC 6851):
|
|
33
33
|
// emulate MOVE using COPY + flag as \Deleted + EXPUNGE.
|
|
34
|
-
if (!connection
|
|
34
|
+
if (!hasCapability(connection, 'MOVE')) {
|
|
35
35
|
let result = await connection.messageCopy(range, destination, options);
|
|
36
36
|
await connection.messageDelete(range, Object.assign({ silent: true }, options));
|
|
37
37
|
return result;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { hasCapability } = require('../tools.js');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Requests NAMESPACE info from the server.
|
|
5
7
|
*
|
|
@@ -12,7 +14,7 @@ module.exports = async connection => {
|
|
|
12
14
|
return;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
if (!connection
|
|
17
|
+
if (!hasCapability(connection, 'NAMESPACE')) {
|
|
16
18
|
// Fallback: when the server does not support the NAMESPACE extension (RFC 2342),
|
|
17
19
|
// derive the prefix and delimiter from a LIST "" "" command, which returns
|
|
18
20
|
// the hierarchy delimiter and root name for the default mailbox hierarchy.
|
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
|
});
|