imapflow 1.4.7 → 1.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/CLAUDE.md +10 -1
  4. package/Gruntfile.js +3 -1
  5. package/lib/commands/authenticate.js +13 -1
  6. package/lib/commands/enable.js +13 -4
  7. package/lib/commands/expunge.js +2 -2
  8. package/lib/commands/idle.js +6 -3
  9. package/lib/commands/list.js +237 -60
  10. package/lib/commands/move.js +2 -2
  11. package/lib/commands/namespace.js +3 -1
  12. package/lib/commands/search.js +88 -13
  13. package/lib/commands/status.js +13 -25
  14. package/lib/imap-flow.d.ts +5 -3
  15. package/lib/imap-flow.js +43 -9
  16. package/lib/search-compiler.js +15 -1
  17. package/lib/tools.js +141 -9
  18. package/package.json +7 -6
  19. package/test/commands-branches-test.js +11 -4
  20. package/test/commands-integration-test.js +1270 -124
  21. package/test/connection-edge-cases-test.js +2 -2
  22. package/test/connection-test.js +1 -1
  23. package/test/fixtures/test-tls.js +2 -2
  24. package/test/imap-flow-coverage-test.js +8 -1
  25. package/test/imap-flow-fetch-download-test.js +1 -4
  26. package/test/imap-flow-internals-test.js +2 -2
  27. package/test/imap-flow-methods-test.js +65 -6
  28. package/test/imap-parser-test.js +1 -2
  29. package/test/integration/README.md +40 -0
  30. package/test/integration/dovecot-test.conf +27 -0
  31. package/test/integration/rev2-live-test.js +242 -0
  32. package/test/integration/run-rev2-tests.sh +61 -0
  33. package/test/reliability-improvements-test.js +4 -1
  34. package/test/search-compiler-test.js +19 -0
  35. package/test/search-test.js +52 -54
  36. package/test/tools-test.js +134 -15
  37. package/.github/codeql/codeql-config.yml +0 -12
  38. package/.github/workflows/codeql.yml +0 -102
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.4.7"
2
+ ".": "1.4.9"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
11
+ ## [1.4.8](https://github.com/postalsys/imapflow/compare/v1.4.7...v1.4.8) (2026-07-21)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **auth:** describe the real connection in the OAUTHBEARER payload ([aca7cb3](https://github.com/postalsys/imapflow/commit/aca7cb3ab7e67704cb692ad59f6db0792040a888))
17
+ * default a non-secure connection to IMAP port 143, not POP3 110 ([5896488](https://github.com/postalsys/imapflow/commit/589648821c5dd1b1750959fa2917b3571ba93db5))
18
+
3
19
  ## [1.4.7](https://github.com/postalsys/imapflow/compare/v1.4.6...v1.4.7) (2026-07-10)
4
20
 
5
21
 
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
- all: ['test/**/*-test.js']
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
 
@@ -40,7 +40,19 @@ async function authOauth(connection, username, accessToken) {
40
40
  // OAUTHBEARER payload per RFC 7628: fields separated by \x01 (SASL GS2 framing).
41
41
  // Format: "n,a=<user>," \x01 "host=..." \x01 "port=..." \x01 "auth=Bearer <token>" \x01 \x01
42
42
  // The trailing empty strings produce the required double-\x01 terminator.
43
- oauthbearer = [`n,a=${username},`, `host=${connection.servername}`, `port=993`, `auth=Bearer ${accessToken}`, '', ''].join('\x01');
43
+ // Both fields must describe the connection actually in use. The port was hardcoded to 993,
44
+ // so an OAuth2 server reached over 143/STARTTLS advertised a payload that did not match, and
45
+ // `servername` is set to false for a bare-IP host, which rendered as a literal "host=false".
46
+ // A server validating either field rejects with status `invalid_request` - a permanent
47
+ // failure that refreshing the access token can never clear.
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');
44
56
  command = 'OAUTHBEARER';
45
57
  // "AQ==" is base64 for \x01 -- sent as the error continuation to abort the SASL exchange
46
58
  breaker = 'AQ==';
@@ -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
- if (!connection.capabilities.has('ENABLE') || connection.state !== connection.states.AUTHENTICATED) {
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
- extensionList = extensionList.filter(extension => connection.capabilities.has(extension.toUpperCase()));
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
- connection.enabled = enabled;
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;
@@ -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.capabilities.has('UIDPLUS');
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
 
@@ -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 real-time push notifications.
144
- // Otherwise, fall back to periodic polling with NOOP/STATUS/SELECT.
145
- if (connection.capabilities.has('IDLE')) {
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
@@ -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
- let listCommand = connection.capabilities.has('XLIST') && !connection.capabilities.has('SPECIAL-USE') ? 'XLIST' : 'LIST';
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
- let entries = [];
37
-
38
- // statusMap caches STATUS responses received inline via LIST-STATUS extension,
39
- // keyed by normalized mailbox path. This avoids separate STATUS commands per mailbox.
40
- let statusMap = new Map();
41
- let returnArgs = [];
42
- let statusQueryAttributes = [];
43
-
44
- // Build the list of STATUS data items to request (MESSAGES, UIDNEXT, etc.)
45
- if (options.statusQuery) {
46
- Object.keys(options.statusQuery).forEach(key => {
47
- if (!options.statusQuery[key]) {
48
- return;
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
- switch (key.toUpperCase()) {
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
- // Tracks all candidate mailboxes for each special-use type (e.g., \\Sent).
80
- // Multiple mailboxes may claim the same type via different sources (user hint,
81
- // server extension, name match). After listing, the best match wins.
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
- if (entry.path.toUpperCase() === 'INBOX') {
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.capabilities.has('SPECIAL-USE'),
209
+ connection.capabilities.has('XLIST') || hasCapability(connection, 'SPECIAL-USE'),
181
210
  entry
182
211
  );
183
212
 
184
- if (specialUseFlag) {
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
- await runList(normalizedReference, normalizePath(connection, mailbox || '', true));
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
- await runList('', 'INBOX');
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
- response = await connection.exec(
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
  }
@@ -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.capabilities.has('MOVE')) {
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.capabilities.has('NAMESPACE')) {
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.