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.
- package/.github/workflows/test.yml +20 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +22 -0
- package/CLAUDE.md +3 -5
- package/lib/commands/fetch.js +18 -14
- package/lib/commands/idle.js +197 -104
- package/lib/commands/list.js +19 -8
- package/lib/commands/quota.js +3 -0
- package/lib/commands/select.js +5 -0
- package/lib/commands/status.js +10 -1
- package/lib/connection-deadline.js +98 -0
- package/lib/handler/imap-compiler.js +20 -14
- package/lib/handler/imap-stream.js +141 -50
- package/lib/handler/limits.js +43 -0
- package/lib/handler/token-parser.js +38 -1
- package/lib/imap-flow.d.ts +47 -5
- package/lib/imap-flow.js +594 -283
- package/lib/proxy-connection.js +393 -98
- package/lib/special-use.js +660 -51
- package/lib/tools.js +52 -3
- package/package.json +2 -2
- package/test/commands-branches-test.js +17 -1
- package/test/commands-integration-test.js +353 -2
- package/test/connection-edge-cases-test.js +4 -40
- package/test/fixtures/fake-timers.js +115 -0
- package/test/handler-branches-test.js +4 -28
- package/test/idle-polling-test.js +349 -0
- package/test/imap-compiler-test.js +85 -0
- package/test/imap-flow-compress-test.js +12 -0
- package/test/imap-flow-coverage-test.js +3 -3
- package/test/imap-flow-fetch-download-test.js +56 -0
- package/test/imap-flow-internals-test.js +23 -0
- package/test/imap-flow-proxy-paths-test.js +151 -0
- package/test/imap-flow-secure-test.js +182 -9
- package/test/imap-flow-server-test.js +229 -0
- package/test/imap-parser-test.js +112 -1
- package/test/imap-stream-test.js +46 -0
- package/test/integration/README.md +17 -5
- package/test/integration/rev2-live-test.js +125 -0
- package/test/integration/run-rev2-tests.sh +14 -0
- package/test/parser-limits-test.js +274 -0
- package/test/proxy-connection-test.js +553 -442
- package/test/reliability-improvements-test.js +87 -0
- package/test/search-compiler-test.js +17 -0
- package/test/special-use-test.js +337 -0
- package/test/tag-correlation-test.js +333 -0
- package/test/timer-policy-test.js +214 -0
- 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
|
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`, `
|
|
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
|
|
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).
|
|
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
|
|
package/lib/commands/fetch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
95
|
+
setBodyPeek(null, partial);
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
// Always request a unique email ID for message deduplication.
|
package/lib/commands/idle.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
connection.preCheck
|
|
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
|
-
//
|
|
153
|
-
|
|
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
|
-
|
|
170
|
-
return runIdleLoop();
|
|
330
|
+
if (!stillIdling) {
|
|
331
|
+
return resp;
|
|
171
332
|
}
|
|
172
|
-
|
|
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
|
-
|
|
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
|
};
|
package/lib/commands/list.js
CHANGED
|
@@ -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
|
|
27
|
-
// claim the same special-use type, the highest-priority
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
523
|
+
sortedEntries[0].entry.specialUseSource = PUBLIC_SOURCE[source] || source;
|
|
513
524
|
}
|
|
514
525
|
}
|
|
515
526
|
|
package/lib/commands/quota.js
CHANGED
|
@@ -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;
|
package/lib/commands/select.js
CHANGED
|
@@ -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) {
|
package/lib/commands/status.js
CHANGED
|
@@ -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) {
|