imapflow 1.4.8 → 1.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/CLAUDE.md +10 -1
- 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/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 +39 -7
- package/lib/search-compiler.js +15 -1
- package/lib/tools.js +141 -9
- package/package.json +3 -2
- package/test/commands-branches-test.js +11 -4
- package/test/commands-integration-test.js +1163 -72
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.9](https://github.com/postalsys/imapflow/compare/v1.4.8...v1.4.9) (2026-07-22)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **folder:** pass StatusObject value instead of its boolean primitive ([043e248](https://github.com/postalsys/imapflow/commit/043e24802f2d73fac297d051a673fe9bc9eb3b25))
|
|
9
|
+
* **list:** request subscription state inline and support IMAP4rev2 ([93e899d](https://github.com/postalsys/imapflow/commit/93e899ded64b219e7fbac2eff083890cb4baa382))
|
|
10
|
+
|
|
3
11
|
## [1.4.8](https://github.com/postalsys/imapflow/compare/v1.4.7...v1.4.8) (2026-07-21)
|
|
4
12
|
|
|
5
13
|
|
package/CLAUDE.md
CHANGED
|
@@ -38,13 +38,15 @@ npm run coverage # Run tests under c8 coverage (text + html reports)
|
|
|
38
38
|
npm run lint # Lint with ESLint
|
|
39
39
|
npm run format # Format with Prettier (js, json, md, yml, yaml)
|
|
40
40
|
npm run update # Refresh deps: remove node_modules + lockfile, ncu -u, npm install
|
|
41
|
+
npm run test:rev2 # Live IMAP4rev2 tests against Dovecot in Docker (see test/integration/)
|
|
41
42
|
```
|
|
42
43
|
|
|
43
44
|
## Testing
|
|
44
45
|
|
|
45
|
-
- Tests live in `test/` and are named `*-test.js`; the Grunt nodeunit glob only matches that pattern, so helpers/fixtures are never run as tests.
|
|
46
|
+
- Tests live in `test/` and are named `*-test.js`; the Grunt nodeunit glob only matches that pattern, so helpers/fixtures are never run as tests. Exception: `test/integration/` is excluded from the glob - those tests need Docker and run only via `npm run test:rev2`.
|
|
46
47
|
- `npm test` runs `grunt`, which runs ESLint first, then the nodeunit suite. Keep the suite green and lint-clean before committing.
|
|
47
48
|
- New tests go in `test/` as `*-test.js`. The parser, command compiler, and search compiler are the most security-sensitive areas - add hostile/malformed-input cases there.
|
|
49
|
+
- `npm run test:rev2` starts a Dovecot 2.4 container (real IMAP4rev2 server) and runs `test/integration/rev2-live-test.js` against it - use it to verify rev2-facing changes end to end, mocks alone are not enough.
|
|
48
50
|
|
|
49
51
|
## Packaging Constraints (IMPORTANT)
|
|
50
52
|
|
|
@@ -59,6 +61,13 @@ CommonJS-compatible:
|
|
|
59
61
|
- ImapFlow source stays CommonJS (`require`/`module.exports`). Do not convert the library to ESM.
|
|
60
62
|
- Do not add a dependency that is pure ESM (`"type": "module"` with only an `import`/ESM entry and no CommonJS export). It must be `require()`-able.
|
|
61
63
|
- When `npm run update` or a new dependency would pull in a pure-ESM package (a common outcome of major-version bumps), pin to the last CommonJS-compatible version instead, or find a CommonJS alternative. Verify with a quick `require()` of the package after updating.
|
|
64
|
+
- After every `npm run update`, run this check to confirm all production dependencies are still CommonJS (it must print `CJS OK` for every dependency and report no pure-ESM packages), then run `npm test`:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
node -e "Object.keys(require('./package.json').dependencies).forEach(d => { require(d); console.log('CJS OK:', d); })"
|
|
68
|
+
node -e "const fs=require('fs');const bad=Object.keys(require('./package.json').dependencies).filter(d=>JSON.parse(fs.readFileSync(require.resolve(d+'/package.json'),'utf8')).type==='module');console.log(bad.length?'PURE-ESM DEPS FOUND: '+bad.join(', '):'No pure-ESM production dependencies')"
|
|
69
|
+
```
|
|
70
|
+
|
|
62
71
|
- Keep dynamic `require()` paths static enough for `pkg` to detect; avoid building module paths at runtime in ways the bundler can't trace.
|
|
63
72
|
|
|
64
73
|
## Code Style Rules
|
package/Gruntfile.js
CHANGED
|
@@ -8,7 +8,9 @@ module.exports = function (grunt) {
|
|
|
8
8
|
},
|
|
9
9
|
|
|
10
10
|
nodeunit: {
|
|
11
|
-
|
|
11
|
+
// test/integration is excluded: those tests need a live Docker server
|
|
12
|
+
// and run via `npm run test:rev2` instead
|
|
13
|
+
all: ['test/**/*-test.js', '!test/integration/**']
|
|
12
14
|
}
|
|
13
15
|
});
|
|
14
16
|
|
|
@@ -45,9 +45,14 @@ async function authOauth(connection, username, accessToken) {
|
|
|
45
45
|
// `servername` is set to false for a bare-IP host, which rendered as a literal "host=false".
|
|
46
46
|
// A server validating either field rejects with status `invalid_request` - a permanent
|
|
47
47
|
// failure that refreshing the access token can never clear.
|
|
48
|
-
oauthbearer = [
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
oauthbearer = [
|
|
49
|
+
`n,a=${username},`,
|
|
50
|
+
`host=${connection.servername || connection.host}`,
|
|
51
|
+
`port=${connection.port}`,
|
|
52
|
+
`auth=Bearer ${accessToken}`,
|
|
53
|
+
'',
|
|
54
|
+
''
|
|
55
|
+
].join('\x01');
|
|
51
56
|
command = 'OAUTHBEARER';
|
|
52
57
|
// "AQ==" is base64 for \x01 -- sent as the error continuation to abort the SASL exchange
|
|
53
58
|
breaker = 'AQ==';
|
package/lib/commands/enable.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { hasCapability } = require('../tools.js');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Enables IMAP extensions on the server.
|
|
5
7
|
*
|
|
@@ -8,14 +10,18 @@
|
|
|
8
10
|
* @returns {Promise<Set|boolean|undefined>} Set of enabled extensions, false on failure, or undefined if not applicable
|
|
9
11
|
*/
|
|
10
12
|
module.exports = async (connection, extensionList) => {
|
|
11
|
-
|
|
13
|
+
// ENABLE is part of base IMAP4rev2, so rev2-only servers may omit the token
|
|
14
|
+
if (!hasCapability(connection, 'ENABLE') || connection.state !== connection.states.AUTHENTICATED) {
|
|
12
15
|
// nothing to do here
|
|
13
16
|
return;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
// Pre-filter: only request extensions the server actually advertised in its
|
|
17
20
|
// CAPABILITY response. Requesting unsupported extensions would cause an error.
|
|
18
|
-
|
|
21
|
+
// Compared case-insensitively - the capability map keeps canonical casing for
|
|
22
|
+
// some keys (e.g. IMAP4rev2).
|
|
23
|
+
let advertised = new Set([...connection.capabilities.keys()].map(capability => capability.toUpperCase()));
|
|
24
|
+
extensionList = extensionList.filter(extension => advertised.has(extension.toUpperCase()));
|
|
19
25
|
if (!extensionList.length) {
|
|
20
26
|
return;
|
|
21
27
|
}
|
|
@@ -44,9 +50,12 @@ module.exports = async (connection, extensionList) => {
|
|
|
44
50
|
}
|
|
45
51
|
}
|
|
46
52
|
);
|
|
47
|
-
|
|
53
|
+
// Merge instead of replace - the untagged ENABLED response only lists
|
|
54
|
+
// extensions enabled by this command (RFC 5161), so a replace would drop
|
|
55
|
+
// grants from an earlier ENABLE call
|
|
56
|
+
connection.enabled = new Set([...connection.enabled, ...enabled]);
|
|
48
57
|
response.next();
|
|
49
|
-
return enabled;
|
|
58
|
+
return connection.enabled;
|
|
50
59
|
} catch (err) {
|
|
51
60
|
connection.log.warn({ err, cid: connection.id });
|
|
52
61
|
return false;
|
package/lib/commands/expunge.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { enhanceCommandError } = require('../tools.js');
|
|
3
|
+
const { enhanceCommandError, hasCapability } = require('../tools.js');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Deletes specified messages by flagging them as Deleted and expunging.
|
|
@@ -27,7 +27,7 @@ module.exports = async (connection, range, options) => {
|
|
|
27
27
|
// With UIDPLUS (RFC 4315): "UID EXPUNGE <uids>" removes only the specified UIDs,
|
|
28
28
|
// leaving other \Deleted messages untouched -- important for concurrent access.
|
|
29
29
|
// Without UIDPLUS: plain "EXPUNGE" removes ALL messages flagged \Deleted in the mailbox.
|
|
30
|
-
let byUid = options.uid && connection
|
|
30
|
+
let byUid = options.uid && hasCapability(connection, 'UIDPLUS');
|
|
31
31
|
let command = byUid ? 'UID EXPUNGE' : 'EXPUNGE';
|
|
32
32
|
let attributes = byUid ? [{ type: 'SEQUENCE', value: range }] : false;
|
|
33
33
|
|
package/lib/commands/idle.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { hasCapability } = require('../tools.js');
|
|
4
|
+
|
|
3
5
|
const NOOP_INTERVAL = 2 * 60 * 1000;
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -140,9 +142,10 @@ module.exports = async (connection, maxIdleTime) => {
|
|
|
140
142
|
return;
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
// If server supports IDLE (RFC 2177), use it for
|
|
144
|
-
// Otherwise, fall back to periodic polling with
|
|
145
|
-
|
|
145
|
+
// If server supports IDLE (RFC 2177, folded into base IMAP4rev2), use it for
|
|
146
|
+
// real-time push notifications. Otherwise, fall back to periodic polling with
|
|
147
|
+
// NOOP/STATUS/SELECT.
|
|
148
|
+
if (hasCapability(connection, 'IDLE')) {
|
|
146
149
|
let idleTimer;
|
|
147
150
|
let stillIdling = false;
|
|
148
151
|
// IDLE loop: runs IDLE, and if maxIdleTime is reached, breaks and restarts
|
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
|
|
|
@@ -239,7 +271,87 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
239
271
|
};
|
|
240
272
|
|
|
241
273
|
let normalizedReference = normalizePath(connection, reference || '');
|
|
242
|
-
|
|
274
|
+
let normalizedMailbox = normalizePath(connection, mailbox || '', true);
|
|
275
|
+
|
|
276
|
+
// Retry stages for the main listing: start with all applicable RETURN options
|
|
277
|
+
// and drop one option group per retry. Consecutive stages differ by exactly one
|
|
278
|
+
// group, so a success right after a rejection identifies the offending group
|
|
279
|
+
// and only that group's skip flag is latched for the rest of the connection.
|
|
280
|
+
// When a stage carrying the auxiliary SPECIAL-USE/CHILDREN options is rejected,
|
|
281
|
+
// a copy of the same stage without them is inserted first (once per listing),
|
|
282
|
+
// so an auxiliary-only rejection does not get a whole option group blamed.
|
|
283
|
+
let stages = [];
|
|
284
|
+
if (canRequestStatus && canRequestSubscribed) {
|
|
285
|
+
stages.push({ status: true, subscribed: true });
|
|
286
|
+
}
|
|
287
|
+
if (canRequestStatus) {
|
|
288
|
+
stages.push({ status: true, subscribed: false });
|
|
289
|
+
} else if (canRequestSubscribed) {
|
|
290
|
+
stages.push({ status: false, subscribed: true });
|
|
291
|
+
}
|
|
292
|
+
stages.push({ status: false, subscribed: false });
|
|
293
|
+
|
|
294
|
+
// A tagged BAD is how servers reject unrecognized RETURN options (RFC 9051
|
|
295
|
+
// section 6.3.9). A tagged NO is an operational failure, and throttling
|
|
296
|
+
// errors (code ETHROTTLE) also surface with a BAD status - neither says
|
|
297
|
+
// anything about the RETURN options, so they propagate to the caller.
|
|
298
|
+
let isRejectedCommand = err => err.responseStatus === 'BAD' && err.code !== 'ETHROTTLE';
|
|
299
|
+
|
|
300
|
+
// Stage of the successful attempt - reused by the INBOX fixup and the LSUB
|
|
301
|
+
// decision below
|
|
302
|
+
let successStage = null;
|
|
303
|
+
|
|
304
|
+
let lastRejectedStage = null;
|
|
305
|
+
let auxRetryInserted = false;
|
|
306
|
+
for (let i = 0; i < stages.length; i++) {
|
|
307
|
+
let stage = stages[i];
|
|
308
|
+
let stageArgs = buildListArgs(stage);
|
|
309
|
+
// Discard partial results from a rejected attempt
|
|
310
|
+
entries = [];
|
|
311
|
+
statusMap = new Map();
|
|
312
|
+
specialUseMatches = {};
|
|
313
|
+
try {
|
|
314
|
+
await runList(normalizedReference, normalizedMailbox, stageArgs);
|
|
315
|
+
if (lastRejectedStage) {
|
|
316
|
+
// Latch only the option group that was present in the rejected
|
|
317
|
+
// attempt but missing from this successful one - that group is
|
|
318
|
+
// proven to be what the server rejects. An unproven group (e.g.
|
|
319
|
+
// SUBSCRIBED when both groups were dropped one by one) is decided
|
|
320
|
+
// by the reduced stage list of the next listing.
|
|
321
|
+
if (lastRejectedStage.subscribed && !stage.subscribed) {
|
|
322
|
+
connection.skipListSubscribedArg = true;
|
|
323
|
+
}
|
|
324
|
+
if (lastRejectedStage.status && !stage.status) {
|
|
325
|
+
connection.skipListStatusArgs = true;
|
|
326
|
+
}
|
|
327
|
+
if (
|
|
328
|
+
stageHasAuxArgs(lastRejectedStage) &&
|
|
329
|
+
stage.aux === false &&
|
|
330
|
+
lastRejectedStage.status === stage.status &&
|
|
331
|
+
lastRejectedStage.subscribed === stage.subscribed
|
|
332
|
+
) {
|
|
333
|
+
// Same option groups, only the auxiliary args dropped - the
|
|
334
|
+
// auxiliaries are proven to be what the server rejects
|
|
335
|
+
connection.skipListAuxArgs = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
successStage = stage;
|
|
339
|
+
break;
|
|
340
|
+
} catch (err) {
|
|
341
|
+
if (i === stages.length - 1 || !isRejectedCommand(err)) {
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
lastRejectedStage = stage;
|
|
345
|
+
if (!auxRetryInserted && stageHasAuxArgs(stage)) {
|
|
346
|
+
// The rejection may be about the auxiliary options rather than the
|
|
347
|
+
// option groups - try the same groups without the auxiliaries before
|
|
348
|
+
// dropping a group
|
|
349
|
+
stages.splice(i + 1, 0, { ...stage, aux: false });
|
|
350
|
+
auxRetryInserted = true;
|
|
351
|
+
}
|
|
352
|
+
connection.log.warn({ msg: 'LIST RETURN options rejected, retrying with reduced options', err, cid: connection.id });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
243
355
|
|
|
244
356
|
if (options.listOnly) {
|
|
245
357
|
return entries;
|
|
@@ -248,17 +360,55 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
248
360
|
// When listing with a namespace prefix (e.g., "INBOX."), INBOX itself may
|
|
249
361
|
// not appear in results. Run a separate LIST for INBOX to ensure it's included.
|
|
250
362
|
if (normalizedReference && !specialUseMatches['\\Inbox']) {
|
|
251
|
-
|
|
363
|
+
let returnArgs = buildListArgs(successStage);
|
|
364
|
+
// Snapshot the accumulator sizes: a rejected fixup attempt may have
|
|
365
|
+
// streamed partial untagged responses before its tagged BAD, and those
|
|
366
|
+
// must be discarded before the retry or INBOX would be listed twice -
|
|
367
|
+
// while the main run's results must be kept
|
|
368
|
+
let entryCountBefore = entries.length;
|
|
369
|
+
let specialUseCountsBefore = {};
|
|
370
|
+
for (let type of Object.keys(specialUseMatches)) {
|
|
371
|
+
specialUseCountsBefore[type] = specialUseMatches[type].length;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
await runList('', 'INBOX', returnArgs);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
// The main listing just succeeded with the same RETURN options, so a
|
|
377
|
+
// rejection here says nothing about the options themselves - retry
|
|
378
|
+
// this one call plain without latching any skip flags. Accepted edge:
|
|
379
|
+
// if the main run filled statusMap, INBOX ends up without inline
|
|
380
|
+
// status data.
|
|
381
|
+
if (!returnArgs.length || !isRejectedCommand(err)) {
|
|
382
|
+
throw err;
|
|
383
|
+
}
|
|
384
|
+
entries.length = entryCountBefore;
|
|
385
|
+
for (let type of Object.keys(specialUseMatches)) {
|
|
386
|
+
if (!(type in specialUseCountsBefore)) {
|
|
387
|
+
delete specialUseMatches[type];
|
|
388
|
+
} else {
|
|
389
|
+
specialUseMatches[type].length = specialUseCountsBefore[type];
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
connection.log.warn({ msg: 'INBOX LIST with RETURN options failed, retrying plain', err, cid: connection.id });
|
|
393
|
+
await runList('', 'INBOX', []);
|
|
394
|
+
}
|
|
252
395
|
}
|
|
253
396
|
|
|
254
397
|
// Attach STATUS data to each selectable mailbox. If LIST-STATUS was used,
|
|
255
398
|
// data is already in statusMap; otherwise, fall back to individual STATUS commands.
|
|
256
399
|
if (options.statusQuery) {
|
|
400
|
+
// RECENT does not exist in IMAP4rev2, so it is never requested from a rev2
|
|
401
|
+
// session - its defined value there is always 0 (the STATUS command module
|
|
402
|
+
// applies the same rule on the per-mailbox fallback path)
|
|
403
|
+
let syntheticRecent = options.statusQuery.recent && isRev2Active(connection);
|
|
257
404
|
for (let entry of entries) {
|
|
258
405
|
// \\Noselect and \\NonExistent mailboxes cannot hold messages
|
|
259
406
|
if (!entry.flags.has('\\Noselect') && !entry.flags.has('\\NonExistent')) {
|
|
260
407
|
if (statusMap.has(entry.path)) {
|
|
261
408
|
entry.status = statusMap.get(entry.path);
|
|
409
|
+
if (syntheticRecent) {
|
|
410
|
+
entry.status.recent = 0;
|
|
411
|
+
}
|
|
262
412
|
} else if (!statusMap.size) {
|
|
263
413
|
// Server didn't support LIST-STATUS; fall back to per-mailbox STATUS
|
|
264
414
|
try {
|
|
@@ -275,10 +425,8 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
275
425
|
// We merge subscription info into the entries already collected from LIST.
|
|
276
426
|
// Subscribed-only mailboxes that weren't in LIST are intentionally ignored
|
|
277
427
|
// (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
|
-
{
|
|
428
|
+
let runLsub = async () => {
|
|
429
|
+
let response = await connection.exec('LSUB', [encodePath(connection, normalizedReference), encodePath(connection, normalizedMailbox)], {
|
|
282
430
|
untagged: {
|
|
283
431
|
LSUB: async untagged => {
|
|
284
432
|
if (!untagged.attributes || !untagged.attributes.length) {
|
|
@@ -316,9 +464,35 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
316
464
|
// Non-listed subscribed folders are intentionally ignored
|
|
317
465
|
}
|
|
318
466
|
}
|
|
467
|
+
});
|
|
468
|
+
response.next();
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// Skipped when RETURN (SUBSCRIBED) already provided subscription state or when
|
|
472
|
+
// this connection's server already rejected LSUB once. Safety net: if the
|
|
473
|
+
// extended LIST was accepted but not a single mailbox came back subscribed on a
|
|
474
|
+
// non-rev2 session, assume the server silently ignored RETURN (SUBSCRIBED) and
|
|
475
|
+
// fall back to LSUB anyway (a rev2 session has no LSUB to fall back to, and an
|
|
476
|
+
// account without any subscriptions legitimately looks the same).
|
|
477
|
+
let needsLsub = !successStage.subscribed || (!isRev2Active(connection) && !entries.some(entry => entry.subscribed));
|
|
478
|
+
if (needsLsub && !connection.skipLsub) {
|
|
479
|
+
try {
|
|
480
|
+
await runLsub();
|
|
481
|
+
} catch (err) {
|
|
482
|
+
if (isRejectedCommand(err)) {
|
|
483
|
+
// Tagged BAD: the server does not recognize the command (IMAP4rev2
|
|
484
|
+
// removed LSUB) - skip LSUB for the rest of this connection
|
|
485
|
+
connection.skipLsub = true;
|
|
486
|
+
} else if (err.responseStatus !== 'NO' || err.code === 'ETHROTTLE') {
|
|
487
|
+
// Transport failures and throttling: rethrow, every follow-up
|
|
488
|
+
// command would fail too or the caller needs to back off
|
|
489
|
+
throw err;
|
|
490
|
+
}
|
|
491
|
+
// Subscription state is auxiliary - keep the LIST results usable. A
|
|
492
|
+
// tagged NO is treated as transient, so the next listing tries again.
|
|
493
|
+
connection.log.warn({ msg: 'Failed to request subscription info', err, cid: connection.id });
|
|
319
494
|
}
|
|
320
|
-
|
|
321
|
-
response.next();
|
|
495
|
+
}
|
|
322
496
|
|
|
323
497
|
// Resolve special-use conflicts: for each type, pick the best candidate
|
|
324
498
|
// based on source priority (user > extension > name), then alphabetically.
|
|
@@ -372,6 +546,9 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
372
546
|
return a.path.localeCompare(b.path);
|
|
373
547
|
});
|
|
374
548
|
} catch (err) {
|
|
549
|
+
// Rewrite the parsed err.response into the response text and set
|
|
550
|
+
// serverResponseCode, same as the other command modules
|
|
551
|
+
await enhanceCommandError(err);
|
|
375
552
|
connection.log.warn({ msg: 'Failed to list folders', err, cid: connection.id });
|
|
376
553
|
throw err;
|
|
377
554
|
}
|
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.
|