imapflow 1.4.9 → 1.6.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.
Files changed (48) hide show
  1. package/.github/workflows/test.yml +20 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/CLAUDE.md +3 -5
  5. package/lib/commands/fetch.js +18 -14
  6. package/lib/commands/idle.js +197 -104
  7. package/lib/commands/list.js +19 -8
  8. package/lib/commands/quota.js +3 -0
  9. package/lib/commands/select.js +5 -0
  10. package/lib/commands/status.js +10 -1
  11. package/lib/connection-deadline.js +98 -0
  12. package/lib/handler/imap-compiler.js +20 -14
  13. package/lib/handler/imap-stream.js +141 -50
  14. package/lib/handler/limits.js +43 -0
  15. package/lib/handler/token-parser.js +38 -1
  16. package/lib/imap-flow.d.ts +47 -5
  17. package/lib/imap-flow.js +594 -283
  18. package/lib/proxy-connection.js +393 -98
  19. package/lib/special-use.js +660 -51
  20. package/lib/tools.js +52 -3
  21. package/package.json +2 -2
  22. package/test/commands-branches-test.js +17 -1
  23. package/test/commands-integration-test.js +353 -2
  24. package/test/connection-edge-cases-test.js +4 -40
  25. package/test/fixtures/fake-timers.js +115 -0
  26. package/test/handler-branches-test.js +4 -28
  27. package/test/idle-polling-test.js +349 -0
  28. package/test/imap-compiler-test.js +85 -0
  29. package/test/imap-flow-compress-test.js +12 -0
  30. package/test/imap-flow-coverage-test.js +3 -3
  31. package/test/imap-flow-fetch-download-test.js +56 -0
  32. package/test/imap-flow-internals-test.js +23 -0
  33. package/test/imap-flow-proxy-paths-test.js +151 -0
  34. package/test/imap-flow-secure-test.js +182 -9
  35. package/test/imap-flow-server-test.js +229 -0
  36. package/test/imap-parser-test.js +112 -1
  37. package/test/imap-stream-test.js +46 -0
  38. package/test/integration/README.md +17 -5
  39. package/test/integration/rev2-live-test.js +125 -0
  40. package/test/integration/run-rev2-tests.sh +14 -0
  41. package/test/parser-limits-test.js +274 -0
  42. package/test/proxy-connection-test.js +553 -442
  43. package/test/reliability-improvements-test.js +87 -0
  44. package/test/search-compiler-test.js +17 -0
  45. package/test/special-use-test.js +337 -0
  46. package/test/tag-correlation-test.js +333 -0
  47. package/test/timer-policy-test.js +214 -0
  48. package/test/tools-test.js +42 -4
@@ -29,3 +29,23 @@ jobs:
29
29
  cache: npm
30
30
  - run: npm install
31
31
  - run: npm test
32
+
33
+ test-rev2:
34
+ # Live IMAP4rev2 integration tests against a real Dovecot 2.4 server in
35
+ # Docker on linux/amd64 (ubuntu runners are amd64 with Docker preinstalled).
36
+ # This is the only place the suite runs on amd64 - Apple Silicon dev
37
+ # machines cannot run the amd64 image under Rosetta.
38
+ name: Live IMAP4rev2 tests (Dovecot, linux/amd64)
39
+ timeout-minutes: 15
40
+ runs-on: ubuntu-latest
41
+ steps:
42
+ - uses: actions/checkout@v6
43
+ - name: Use Node.js 24.x
44
+ uses: actions/setup-node@v6
45
+ with:
46
+ node-version: 24.x
47
+ cache: npm
48
+ - run: npm install
49
+ - run: npm run test:rev2
50
+ env:
51
+ IMAPFLOW_DOVECOT_PLATFORM: linux/amd64
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.4.9"
2
+ ".": "1.6.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.0](https://github.com/postalsys/imapflow/compare/v1.5.0...v1.6.0) (2026-07-27)
4
+
5
+
6
+ ### Features
7
+
8
+ * expand localized special-use folder names from client localization catalogs ([e766c41](https://github.com/postalsys/imapflow/commit/e766c411a71bc2e6578017a9029050e5026ca9de))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * add missing localized special-use folder names and normalize to NFC ([1951438](https://github.com/postalsys/imapflow/commit/19514381a05d519ced1ab52716aa2256f7be169a))
14
+ * declare specialUseHints.archive and specialUseSource in the types ([a372a50](https://github.com/postalsys/imapflow/commit/a372a50a0365b202d1c8d144663f8280ee63214c))
15
+ * harden response parsing, tag correlation, IDLE polling and proxy connection setup ([21fe354](https://github.com/postalsys/imapflow/commit/21fe3541e9eff24060f1ef61f668fdfb5ae18115))
16
+ * match decorated folder names and normalize special use names to NFKC ([8f3bed6](https://github.com/postalsys/imapflow/commit/8f3bed6fcd0daca48ad4ab8446fc1581792fb754))
17
+
18
+ ## [1.5.0](https://github.com/postalsys/imapflow/compare/v1.4.9...v1.5.0) (2026-07-23)
19
+
20
+
21
+ ### Features
22
+
23
+ * add STATUS SIZE/DELETED and rev2 BINARY fetch support, fix protocol bugs found in an RFC 9051 review ([04f5bf6](https://github.com/postalsys/imapflow/commit/04f5bf6c1a49e155dd23b2af062e570530b814b1))
24
+
3
25
  ## [1.4.9](https://github.com/postalsys/imapflow/compare/v1.4.8...v1.4.9) (2026-07-22)
4
26
 
5
27
 
package/CLAUDE.md CHANGED
@@ -28,7 +28,7 @@ npm as `imapflow` and ships TypeScript type definitions.
28
28
  - **Module system**: CommonJS (see Packaging Constraints below)
29
29
  - **Testing**: Grunt + grunt-contrib-nodeunit, ESLint via grunt-eslint
30
30
  - **Lint/format**: ESLint (`eslint.config.js`, flat config) + Prettier
31
- - **Key dependencies**: `@zone-eu/mailsplit`, `libmime`, `libqp`, `libbase64`, `iconv-lite`, `encoding-japanese`, `nodemailer`, `pino`, `socks`
31
+ - **Key dependencies**: `@zone-eu/mailsplit`, `libmime`, `libqp`, `libbase64`, `iconv-lite`, `encoding-japanese`, `pino`, `socks`
32
32
 
33
33
  ## Development Commands
34
34
 
@@ -81,7 +81,7 @@ CommonJS-compatible:
81
81
  1. Run `npm run format` and `npm run lint`
82
82
  2. Run `npm test` and keep it green
83
83
  3. For non-trivial changes, run `/simplify` to review changed code and `/security-review` to check for security issues before committing
84
- - After pushing, check the GitHub Actions runs for the push (e.g. `gh run list --branch master`) and report their status, including the CodeQL "CodeQL Advanced" code-scanning run. If a run fails for a strange or unrelated reason (for example a checkout step reporting "account suspended", HTTP 403, or other auth/infrastructure errors that have nothing to do with the change), check <https://www.githubstatus.com/> for an active GitHub incident before assuming the failure is caused by the change.
84
+ - After pushing, check the GitHub Actions runs for the push (e.g. `gh run list --branch master`) and report their status. If a run fails for a strange or unrelated reason (for example a checkout step reporting "account suspended", HTTP 403, or other auth/infrastructure errors that have nothing to do with the change), check <https://www.githubstatus.com/> for an active GitHub incident before assuming the failure is caused by the change.
85
85
 
86
86
  ## Relationship to EmailEngine
87
87
 
@@ -97,9 +97,7 @@ Packaging Constraints).
97
97
  ## Security
98
98
 
99
99
  Security policy and private reporting channels are documented in
100
- [`SECURITY.md`](SECURITY.md) / [`SECURITY.txt`](SECURITY.txt). Code scanning runs
101
- through the "CodeQL Advanced" GitHub Actions workflow
102
- (`.github/workflows/codeql.yml`, config in `.github/codeql/codeql-config.yml`).
100
+ [`SECURITY.md`](SECURITY.md) / [`SECURITY.txt`](SECURITY.txt).
103
101
 
104
102
  ## Release Process
105
103
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { formatMessageResponse } = require('../tools');
3
+ const { formatMessageResponse, isRev2Active } = require('../tools');
4
4
 
5
5
  /**
6
6
  * Fetches emails from the server.
@@ -25,8 +25,12 @@ module.exports = async (connection, range, query, options) => {
25
25
 
26
26
  let mailbox = connection.mailbox;
27
27
 
28
- // Use BINARY extension for fetching if supported and requested, otherwise fall back to BODY
29
- const commandKey = connection.capabilities.has('BINARY') && options.binary && !connection.disableBinary ? 'BINARY' : 'BODY';
28
+ // Use BINARY extension for fetching if supported and requested, otherwise fall back to BODY.
29
+ // RFC 9051 folds the FETCH side of the BINARY extension into base IMAP4rev2, so an active
30
+ // rev2 session can use it even without the BINARY capability token (the APPEND side is NOT
31
+ // folded in and stays gated on the token in append.js)
32
+ const canUseBinary = connection.capabilities.has('BINARY') || isRev2Active(connection);
33
+ const commandKey = canUseBinary && options.binary && !connection.disableBinary ? 'BINARY' : 'BODY';
30
34
 
31
35
  // Retry logic for ETHROTTLE errors (server rate limiting) with exponential backoff
32
36
  let retryCount = 0;
@@ -50,21 +54,21 @@ module.exports = async (connection, range, query, options) => {
50
54
  // PEEK avoids marking messages as \Seen. Section identifies what to fetch (HEADER, specific part, etc.)
51
55
  // Partial is an optional byte range [start, maxLength].
52
56
  let setBodyPeek = (attributes, partial) => {
57
+ let section = [].concat(attributes || []);
58
+
59
+ // BINARY may only address the empty section or a numeric part specifier
60
+ // (RFC 3516 / RFC 9051 section-binary) - HEADER, HEADER.FIELDS, TEXT and
61
+ // n.MIME are invalid after BINARY and must stay BODY fetches
62
+ let binaryAddressable =
63
+ !section.length || (section.length === 1 && typeof section[0].value === 'string' && /^\d+(\.\d+)*$/.test(section[0].value));
64
+
53
65
  let bodyPeek = {
54
66
  type: 'ATOM',
55
- value: `${commandKey}.PEEK`,
56
- section: [],
67
+ value: `${binaryAddressable ? commandKey : 'BODY'}.PEEK`,
68
+ section,
57
69
  partial
58
70
  };
59
71
 
60
- if (Array.isArray(attributes)) {
61
- attributes.forEach(attribute => {
62
- bodyPeek.section.push(attribute);
63
- });
64
- } else if (attributes) {
65
- bodyPeek.section.push(attributes);
66
- }
67
-
68
72
  queryStructure.push(bodyPeek);
69
73
  };
70
74
 
@@ -88,7 +92,7 @@ module.exports = async (connection, range, query, options) => {
88
92
  partial.push(Number(query.source.maxLength));
89
93
  }
90
94
  }
91
- queryStructure.push({ type: 'ATOM', value: `${commandKey}.PEEK`, section: [], partial });
95
+ setBodyPeek(null, partial);
92
96
  }
93
97
 
94
98
  // Always request a unique email ID for message deduplication.
@@ -1,9 +1,32 @@
1
1
  'use strict';
2
2
 
3
- const { hasCapability } = require('../tools.js');
3
+ const { hasCapability, unrefTimer } = require('../tools.js');
4
4
 
5
5
  const NOOP_INTERVAL = 2 * 60 * 1000;
6
6
 
7
+ /**
8
+ * Marks the connection as idling on behalf of one session and returns a release function.
9
+ *
10
+ * Both IDLE modes use this so ownership of the shared `idling` flag is explicit: a session that
11
+ * finishes late (a poll that completes after cancellation, an IDLE that unwinds after a restart)
12
+ * cannot clear the flag of the session that has since taken over.
13
+ *
14
+ * @param {Object} connection - IMAP connection instance
15
+ * @returns {Function} Release function, safe to call more than once
16
+ */
17
+ function claimIdling(connection) {
18
+ let token = {};
19
+ connection._idleSession = token;
20
+ connection.idling = true;
21
+
22
+ return () => {
23
+ if (connection._idleSession === token) {
24
+ connection._idleSession = null;
25
+ connection.idling = false;
26
+ }
27
+ };
28
+ }
29
+
7
30
  /**
8
31
  * Runs a single IDLE session on the connection.
9
32
  *
@@ -16,9 +39,13 @@ async function runIdle(connection) {
16
39
  // Queue of promises waiting for IDLE to break. When another command needs to run,
17
40
  // it calls connection.preCheck() which queues a promise here and sends DONE to break IDLE.
18
41
  let preCheckWaitQueue = [];
19
- try {
20
- connection.idling = true;
21
42
 
43
+ // The preCheck function this session owns. Only this session may clear it from the
44
+ // connection, so a newer IDLE session that already installed its own is left alone.
45
+ let ownPreCheck = null;
46
+ let releaseIdling = claimIdling(connection);
47
+
48
+ try {
22
49
  // State flags for the IDLE lifecycle:
23
50
  // - doneRequested: someone wants to break IDLE (e.g., to run another command)
24
51
  // - doneSent: we've already sent the DONE command to server
@@ -42,8 +69,10 @@ async function runIdle(connection) {
42
69
  connection.write('DONE');
43
70
  doneSent = true;
44
71
 
45
- connection.idling = false;
46
- connection.preCheck = false; // unset itself
72
+ releaseIdling();
73
+ if (connection.preCheck === ownPreCheck) {
74
+ connection.preCheck = false; // unset itself
75
+ }
47
76
 
48
77
  while (preCheckWaitQueue.length) {
49
78
  let { resolve } = preCheckWaitQueue.shift();
@@ -75,6 +104,7 @@ async function runIdle(connection) {
75
104
  };
76
105
 
77
106
  // Register preCheck on the connection so other code (e.g., getMailboxLock) can break IDLE
107
+ ownPreCheck = connectionPreCheck;
78
108
  connection.preCheck = connectionPreCheck;
79
109
 
80
110
  response = await connection.exec('IDLE', false, {
@@ -95,37 +125,165 @@ async function runIdle(connection) {
95
125
  onSend: () => {}
96
126
  });
97
127
 
98
- // Clean up: unset preCheck and resolve any remaining waiters before processing the response.
99
- // Usually preCheck is already cleared by the DONE handler, but this handles edge cases.
100
- if (typeof connection.preCheck === 'function' && connection.preCheck === connectionPreCheck) {
101
- connection.log.trace({
102
- msg: 'Clearing pre-check function',
103
- lockId: connection.currentLock?.lockId,
104
- path: connection.mailbox && connection.mailbox.path,
105
- queued: preCheckWaitQueue.length,
106
- doneRequested,
107
- canEnd,
108
- doneSent
109
- });
110
- connection.preCheck = false;
111
- while (preCheckWaitQueue.length) {
112
- let { resolve } = preCheckWaitQueue.shift();
113
- resolve();
114
- }
115
- }
116
-
117
128
  response.next();
118
129
  return;
119
130
  } catch (err) {
120
- connection.preCheck = false;
121
- connection.idling = false;
122
-
123
131
  connection.log.warn({ err, cid: connection.id });
124
132
  while (preCheckWaitQueue.length) {
125
133
  let { reject } = preCheckWaitQueue.shift();
126
134
  reject(err);
127
135
  }
128
136
  return false;
137
+ } finally {
138
+ // Single ownership cleanup for every outcome: explicit break, tagged completion
139
+ // (including a server-terminated IDLE, where preCheck never ran), rejected command,
140
+ // parser failure and connection close. `idling` drives socket-timeout handling, so it
141
+ // must always describe reality rather than being left over from a previous state.
142
+ releaseIdling();
143
+ if (connection.preCheck === ownPreCheck) {
144
+ connection.preCheck = false;
145
+ }
146
+ while (preCheckWaitQueue.length) {
147
+ let { resolve } = preCheckWaitQueue.shift();
148
+ resolve();
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Runs one fallback poll. The real SELECT and STATUS commands are reused instead of replaying the
155
+ * saved wire arguments, so a poll applies exactly the same mailbox state transitions, events and
156
+ * failure handling as a caller-issued command. They go through connection.runInternal() rather
157
+ * than connection.run(), because run() awaits preCheck() - and the preCheck it would await is the
158
+ * one this very polling session installed, so the session would cancel itself.
159
+ *
160
+ * @param {Object} connection - IMAP connection instance
161
+ * @param {Object} session - Polling session state
162
+ * @returns {Promise<void>}
163
+ */
164
+ async function pollOnce(connection, session) {
165
+ let path = connection.mailbox && connection.mailbox.path;
166
+
167
+ switch (connection.missingIdleCommand) {
168
+ case 'SELECT':
169
+ connection.log.debug({ src: 'c', msg: `Running SELECT to detect changes in folder`, cid: connection.id });
170
+ await connection.runInternal('SELECT', path, { readOnly: session.selectCommand.command === 'EXAMINE' });
171
+ break;
172
+
173
+ case 'STATUS': {
174
+ connection.log.debug({ src: 'c', msg: `Running STATUS to detect changes in folder`, cid: connection.id });
175
+ // HIGHESTMODSEQ is filtered out again unless the server advertises CONDSTORE, so a
176
+ // CONDSTORE session keeps mailbox.highestModseq current without asking a plain
177
+ // server for an item it does not know.
178
+ let status = await connection.runInternal('STATUS', path, {
179
+ messages: true,
180
+ uidNext: true,
181
+ uidValidity: true,
182
+ unseen: true,
183
+ highestModseq: true
184
+ });
185
+ if (!status) {
186
+ let err = new Error('STATUS poll failed');
187
+ err.code = 'PollFailed';
188
+ throw err;
189
+ }
190
+ break;
191
+ }
192
+
193
+ case 'NOOP':
194
+ default: {
195
+ let response = await connection.exec('NOOP', false, { comment: 'IDLE not supported' });
196
+ response.next();
197
+ break;
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Polls the selected mailbox at a fixed interval, for servers without IDLE support.
204
+ *
205
+ * The loop is one explicitly identified session: cancellation is idempotent, is checked before
206
+ * a poll starts and again before the next timer is scheduled, and a poll that completes after
207
+ * cancellation can neither run again nor take ownership away from a newer IDLE session.
208
+ *
209
+ * @param {Object} connection - IMAP connection instance
210
+ * @param {number} [maxIdleTime] - Upper bound for the polling interval
211
+ * @returns {Promise<void>}
212
+ */
213
+ async function runPollingFallback(connection, maxIdleTime) {
214
+ if (!connection.currentSelectCommand) {
215
+ return;
216
+ }
217
+
218
+ let session = {
219
+ cancelled: false,
220
+ timer: null,
221
+ preCheck: null,
222
+ selectCommand: connection.currentSelectCommand
223
+ };
224
+
225
+ let interval = maxIdleTime ? Math.min(NOOP_INTERVAL, maxIdleTime) : NOOP_INTERVAL;
226
+ let releaseIdling = claimIdling(connection);
227
+
228
+ try {
229
+ await new Promise(resolve => {
230
+ // Idempotent cancellation. Never keyed off connection.preCheck, because a newer IDLE
231
+ // session may already own that property by the time an old poll settles.
232
+ const cancel = () => {
233
+ if (session.cancelled) {
234
+ return;
235
+ }
236
+ session.cancelled = true;
237
+ clearTimeout(session.timer);
238
+ session.timer = null;
239
+ resolve();
240
+ };
241
+
242
+ session.preCheck = async () => {
243
+ connection.log.debug({ src: 'c', msg: `breaking NOOP loop`, cid: connection.id });
244
+ cancel();
245
+ };
246
+ connection.preCheck = session.preCheck;
247
+
248
+ const runPoll = () => {
249
+ if (session.cancelled) {
250
+ return;
251
+ }
252
+
253
+ // The transport or the mailbox may be gone by the time the timer fires
254
+ if (!connection.socket || connection.socket.destroyed || connection.state !== connection.states.SELECTED || !connection.mailbox) {
255
+ return cancel();
256
+ }
257
+
258
+ pollOnce(connection, session)
259
+ .then(() => {
260
+ // Cancellation is re-checked here: the session may have been broken while
261
+ // this poll was in flight, and an orphaned poller must not schedule again.
262
+ if (session.cancelled) {
263
+ return;
264
+ }
265
+ session.timer = setTimeout(runPoll, interval);
266
+ // Background polling must not keep the process alive
267
+ unrefTimer(session.timer);
268
+ })
269
+ .catch(err => {
270
+ connection.log.warn({ err, cid: connection.id });
271
+ cancel();
272
+ });
273
+ };
274
+
275
+ connection.log.debug({ src: 'c', msg: `initiated NOOP loop`, cid: connection.id });
276
+ // Keep the immediate first poll
277
+ runPoll();
278
+ });
279
+ } finally {
280
+ session.cancelled = true;
281
+ clearTimeout(session.timer);
282
+ session.timer = null;
283
+ releaseIdling();
284
+ if (connection.preCheck === session.preCheck) {
285
+ connection.preCheck = false;
286
+ }
129
287
  }
130
288
  }
131
289
 
@@ -148,9 +306,11 @@ module.exports = async (connection, maxIdleTime) => {
148
306
  if (hasCapability(connection, 'IDLE')) {
149
307
  let idleTimer;
150
308
  let stillIdling = false;
151
- // IDLE loop: runs IDLE, and if maxIdleTime is reached, breaks and restarts
152
- // to keep the connection alive (some servers drop long-running IDLEs).
153
- let runIdleLoop = async () => {
309
+ // IDLE loop: runs IDLE, and if maxIdleTime is reached, breaks and restarts to keep the
310
+ // connection alive (some servers drop long-running IDLEs). Iterative rather than
311
+ // recursive, so a long-lived idling connection does not retain one pending frame per
312
+ // restart.
313
+ for (;;) {
154
314
  if (maxIdleTime) {
155
315
  idleTimer = setTimeout(() => {
156
316
  if (connection.idling) {
@@ -162,86 +322,19 @@ module.exports = async (connection, maxIdleTime) => {
162
322
  }
163
323
  }
164
324
  }, maxIdleTime);
325
+ // Background IDLE restart timer must not keep the process alive
326
+ unrefTimer(idleTimer);
165
327
  }
166
328
  let resp = await runIdle(connection);
167
329
  clearTimeout(idleTimer);
168
- if (stillIdling) {
169
- stillIdling = false;
170
- return runIdleLoop();
330
+ if (!stillIdling) {
331
+ return resp;
171
332
  }
172
- return resp;
173
- };
174
- return runIdleLoop();
333
+ stillIdling = false;
334
+ }
175
335
  }
176
336
 
177
337
  // Fallback for servers without IDLE support: poll at regular intervals using
178
338
  // NOOP (default), STATUS, or SELECT depending on missingIdleCommand config.
179
- let idleTimer;
180
- return new Promise(resolve => {
181
- if (!connection.currentSelectCommand) {
182
- return resolve();
183
- }
184
-
185
- // Set up preCheck so other commands can break the polling loop
186
- connection.preCheck = async () => {
187
- connection.preCheck = false; // unset itself
188
- clearTimeout(idleTimer);
189
- connection.log.debug({ src: 'c', msg: `breaking NOOP loop` });
190
- connection.idling = false;
191
- resolve();
192
- };
193
-
194
- let selectCommand = connection.currentSelectCommand;
195
-
196
- // Run one polling check. The method used depends on configuration:
197
- // SELECT re-selects the mailbox (may detect changes), STATUS queries mailbox counters,
198
- // NOOP is the simplest but relies on server pushing untagged responses.
199
- let idleCheck = async () => {
200
- let response;
201
- switch (connection.missingIdleCommand) {
202
- case 'SELECT':
203
- // FIXME: somehow a loop occurs after some time of idling with SELECT
204
- connection.log.debug({ src: 'c', msg: `Running SELECT to detect changes in folder` });
205
- response = await connection.exec(selectCommand.command, selectCommand.arguments);
206
- break;
207
-
208
- case 'STATUS':
209
- {
210
- let statusArgs = [
211
- selectCommand.arguments[0],
212
- ['MESSAGES', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN'].map(key => ({ type: 'ATOM', value: key }))
213
- ];
214
- connection.log.debug({ src: 'c', msg: `Running STATUS to detect changes in folder` });
215
- response = await connection.exec('STATUS', statusArgs);
216
- }
217
- break;
218
-
219
- case 'NOOP':
220
- default:
221
- response = await connection.exec('NOOP', false, { comment: 'IDLE not supported' });
222
- break;
223
- }
224
- response.next();
225
- };
226
-
227
- let noopInterval = maxIdleTime ? Math.min(NOOP_INTERVAL, maxIdleTime) : NOOP_INTERVAL;
228
-
229
- let runLoop = () => {
230
- idleCheck()
231
- .then(() => {
232
- clearTimeout(idleTimer);
233
- idleTimer = setTimeout(runLoop, noopInterval);
234
- })
235
- .catch(err => {
236
- clearTimeout(idleTimer);
237
- connection.preCheck = false;
238
- connection.log.warn({ err, cid: connection.id });
239
- resolve();
240
- });
241
- };
242
-
243
- connection.log.debug({ src: 'c', msg: `initiated NOOP loop` });
244
- connection.idling = true;
245
- runLoop();
246
- });
339
+ return runPollingFallback(connection, maxIdleTime);
247
340
  };
@@ -23,9 +23,15 @@ module.exports = async (connection, reference, mailbox, options) => {
23
23
  // Used in the final sort to group special-use mailboxes at the top of the list.
24
24
  const FLAG_SORT_ORDER = ['\\Inbox', '\\Flagged', '\\Sent', '\\Drafts', '\\All', '\\Archive', '\\Junk', '\\Trash'];
25
25
  // Priority for how a special-use flag was determined: explicit user hint > server
26
- // extension flag (SPECIAL-USE/XLIST) > name-based guess. When multiple mailboxes
27
- // claim the same special-use type, the highest-priority source wins.
28
- const SOURCE_SORT_ORDER = ['user', 'extension', 'name'];
26
+ // extension flag (SPECIAL-USE/XLIST) > known localized name > relaxed name guess.
27
+ // When multiple mailboxes claim the same special-use type, the highest-priority
28
+ // source wins, so an exactly named folder beats one matched by a decorated or
29
+ // morphological variant of that name.
30
+ const SOURCE_SORT_ORDER = ['user', 'extension', 'name', 'name-guess'];
31
+ // "name-guess" is an internal precedence tier only. It is reported as "name" so
32
+ // that specialUseSource keeps its documented set of values for consumers.
33
+ const PUBLIC_SOURCE = { 'name-guess': 'name' };
34
+ const isNameSource = source => source === 'name' || source === 'name-guess';
29
35
 
30
36
  // Prefer XLIST (legacy Gmail extension) only if SPECIAL-USE (RFC 6154) is unavailable.
31
37
  // Both provide special-use flags, but SPECIAL-USE is the standardized approach.
@@ -210,10 +216,11 @@ module.exports = async (connection, reference, mailbox, options) => {
210
216
  entry
211
217
  );
212
218
 
213
- // A name-based guess for a \NonExistent phantom entry could win the
219
+ // A name-based match for a \NonExistent phantom entry could win the
214
220
  // 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'))) {
221
+ // are trusted for nonexistent entries. Covers every name-derived
222
+ // source, exact and relaxed alike.
223
+ if (specialUseFlag && (!isNameSource(flagSource) || !entry.flags.has('\\NonExistent'))) {
217
224
  addSpecialUseMatch(entry, specialUseFlag, flagSource);
218
225
  }
219
226
 
@@ -235,7 +242,10 @@ module.exports = async (connection, reference, mailbox, options) => {
235
242
  UIDNEXT: { key: 'uidNext', parser: Number },
236
243
  UIDVALIDITY: { key: 'uidValidity', parser: BigInt },
237
244
  UNSEEN: { key: 'unseen', parser: Number },
238
- HIGHESTMODSEQ: { key: 'highestModseq', parser: BigInt }
245
+ HIGHESTMODSEQ: { key: 'highestModseq', parser: BigInt },
246
+ // IMAP4rev2 additions (RFC 9051): mailbox size and \Deleted count
247
+ SIZE: { key: 'size', parser: Number },
248
+ DELETED: { key: 'deleted', parser: Number }
239
249
  };
240
250
 
241
251
  let key;
@@ -508,8 +518,9 @@ module.exports = async (connection, reference, mailbox, options) => {
508
518
  });
509
519
 
510
520
  if (!sortedEntries[0].entry.specialUse) {
521
+ let source = sortedEntries[0].source;
511
522
  sortedEntries[0].entry.specialUse = type;
512
- sortedEntries[0].entry.specialUseSource = sortedEntries[0].source;
523
+ sortedEntries[0].entry.specialUseSource = PUBLIC_SOURCE[source] || source;
513
524
  }
514
525
  }
515
526
 
@@ -110,6 +110,9 @@ module.exports = async (connection, path) => {
110
110
  }
111
111
  }
112
112
  });
113
+ // Release the parser: without this the connection stalls, because the reader loop
114
+ // waits for the response to be handed back before parsing any further input.
115
+ response.next();
113
116
  }
114
117
 
115
118
  return map;
@@ -25,6 +25,11 @@ module.exports = async (connection, path, options) => {
25
25
 
26
26
  // Ensure we have folder metadata (delimiter, flags, specialUse) by running LIST if needed.
27
27
  // This is cached in connection.folders to avoid repeated LIST calls.
28
+ // Note: this uses run() rather than runInternal(), so it terminates a running IDLE first,
29
+ // which is what a caller-issued mailboxOpen() needs. Fallback polling reaches SELECT through
30
+ // runInternal(), and on a cache miss this LIST awaits the polling session's own preCheck and
31
+ // so cancels that session. Not a deadlock, and only reachable when the folder is uncached,
32
+ // but a polled SELECT of an unlisted folder ends the poll early.
28
33
  if (!connection.folders.has(path)) {
29
34
  let folders = await connection.run('LIST', '', path);
30
35
  if (!folders) {
@@ -86,7 +86,12 @@ module.exports = async (connection, path, query) => {
86
86
  updateMailbox: (val, conn) => {
87
87
  conn.mailbox.highestModseq = val;
88
88
  }
89
- }
89
+ },
90
+ // IMAP4rev2 additions (RFC 9051): total mailbox size in octets
91
+ // (number64, exact as a JS number up to 2^53-1) and count of
92
+ // messages with the \Deleted flag
93
+ SIZE: { key: 'size', parser: Number },
94
+ DELETED: { key: 'deleted', parser: Number }
90
95
  };
91
96
 
92
97
  let key;
@@ -127,6 +132,10 @@ module.exports = async (connection, path, query) => {
127
132
  // A NO response usually means the mailbox doesn't exist. Verify by
128
133
  // running LIST -- if no results, throw a clear NotFound error instead
129
134
  // of the generic IMAP error.
135
+ // Note: this uses run(), so when STATUS was dispatched by fallback polling through
136
+ // runInternal() the LIST awaits that polling session's own preCheck and cancels it.
137
+ // Not a deadlock, and only reachable when the server rejects the STATUS, but a polled
138
+ // STATUS of a missing folder ends the poll early.
130
139
  if (err.responseStatus === 'NO') {
131
140
  let folders = await connection.run('LIST', '', path, { listOnly: true });
132
141
  if (folders && !folders.length) {