imapflow 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +22 -0
- package/CLAUDE.md +1 -1
- package/lib/commands/idle.js +197 -104
- package/lib/commands/list.js +15 -7
- package/lib/commands/quota.js +3 -0
- package/lib/commands/select.js +5 -0
- package/lib/commands/status.js +4 -0
- package/lib/connection-deadline.js +98 -0
- package/lib/handler/imap-compiler.js +8 -5
- package/lib/handler/imap-stream.js +141 -50
- package/lib/handler/limits.js +43 -0
- package/lib/handler/token-parser.js +31 -1
- package/lib/imap-flow.d.ts +33 -5
- package/lib/imap-flow.js +583 -281
- package/lib/proxy-connection.js +393 -98
- package/lib/special-use.js +660 -51
- package/lib/tools.js +17 -0
- package/package.json +2 -2
- package/test/commands-branches-test.js +17 -1
- package/test/commands-integration-test.js +24 -2
- package/test/fixtures/fake-timers.js +115 -0
- package/test/handler-branches-test.js +0 -25
- package/test/idle-polling-test.js +349 -0
- package/test/imap-flow-compress-test.js +12 -0
- package/test/imap-flow-coverage-test.js +3 -3
- 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 +159 -0
- package/test/imap-flow-server-test.js +186 -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/special-use-test.js +337 -0
- package/test/tag-correlation-test.js +333 -0
- package/test/timer-policy-test.js +214 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.6.1](https://github.com/postalsys/imapflow/compare/v1.6.0...v1.6.1) (2026-07-27)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* keep the authentication result on a verifyOnly connection ([8665c64](https://github.com/postalsys/imapflow/commit/8665c642f16988c630a779fa796c5801532ffd4a))
|
|
9
|
+
|
|
10
|
+
## [1.6.0](https://github.com/postalsys/imapflow/compare/v1.5.0...v1.6.0) (2026-07-27)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* expand localized special-use folder names from client localization catalogs ([e766c41](https://github.com/postalsys/imapflow/commit/e766c411a71bc2e6578017a9029050e5026ca9de))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* add missing localized special-use folder names and normalize to NFC ([1951438](https://github.com/postalsys/imapflow/commit/19514381a05d519ced1ab52716aa2256f7be169a))
|
|
21
|
+
* declare specialUseHints.archive and specialUseSource in the types ([a372a50](https://github.com/postalsys/imapflow/commit/a372a50a0365b202d1c8d144663f8280ee63214c))
|
|
22
|
+
* harden response parsing, tag correlation, IDLE polling and proxy connection setup ([21fe354](https://github.com/postalsys/imapflow/commit/21fe3541e9eff24060f1ef61f668fdfb5ae18115))
|
|
23
|
+
* match decorated folder names and normalize special use names to NFKC ([8f3bed6](https://github.com/postalsys/imapflow/commit/8f3bed6fcd0daca48ad4ab8446fc1581792fb754))
|
|
24
|
+
|
|
3
25
|
## [1.5.0](https://github.com/postalsys/imapflow/compare/v1.4.9...v1.5.0) (2026-07-23)
|
|
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
|
|
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
|
|
|
@@ -511,8 +518,9 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
511
518
|
});
|
|
512
519
|
|
|
513
520
|
if (!sortedEntries[0].entry.specialUse) {
|
|
521
|
+
let source = sortedEntries[0].source;
|
|
514
522
|
sortedEntries[0].entry.specialUse = type;
|
|
515
|
-
sortedEntries[0].entry.specialUseSource =
|
|
523
|
+
sortedEntries[0].entry.specialUseSource = PUBLIC_SOURCE[source] || source;
|
|
516
524
|
}
|
|
517
525
|
}
|
|
518
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
|
@@ -132,6 +132,10 @@ module.exports = async (connection, path, query) => {
|
|
|
132
132
|
// A NO response usually means the mailbox doesn't exist. Verify by
|
|
133
133
|
// running LIST -- if no results, throw a clear NotFound error instead
|
|
134
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.
|
|
135
139
|
if (err.responseStatus === 'NO') {
|
|
136
140
|
let folders = await connection.run('LIST', '', path, { listOnly: true });
|
|
137
141
|
if (folders && !folders.length) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Default upper bound for establishing a usable transport, including DNS and proxy negotiation.
|
|
4
|
+
const CONNECT_TIMEOUT = 90 * 1000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One deadline for an entire connection attempt.
|
|
8
|
+
*
|
|
9
|
+
* DNS resolution, proxy negotiation and the transport handshake all draw from the same budget, so
|
|
10
|
+
* a phase that stalls cannot extend the documented `connectionTimeout`. Every expiry - whether it
|
|
11
|
+
* comes from this deadline or is normalized from a dependency - is reported with the same
|
|
12
|
+
* `CONNECT_TIMEOUT` error shape, so callers do not need to know which phase was blocked.
|
|
13
|
+
*/
|
|
14
|
+
class ConnectionDeadline {
|
|
15
|
+
/**
|
|
16
|
+
* @param {Number} [timeout] Configured connection timeout in milliseconds. Normalized once
|
|
17
|
+
* here; 0 and any other falsy or invalid value fall back to the 90 second default.
|
|
18
|
+
*/
|
|
19
|
+
constructor(timeout) {
|
|
20
|
+
this.timeout = Number(timeout) || CONNECT_TIMEOUT;
|
|
21
|
+
this.startedAt = Date.now();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @returns {Number} Milliseconds left in the budget, never negative.
|
|
26
|
+
*/
|
|
27
|
+
remaining() {
|
|
28
|
+
return Math.max(0, this.timeout - (Date.now() - this.startedAt));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @returns {Error} The shared `CONNECT_TIMEOUT` error.
|
|
33
|
+
*/
|
|
34
|
+
error() {
|
|
35
|
+
let err = new Error('Failed to establish connection in required time');
|
|
36
|
+
err.code = 'CONNECT_TIMEOUT';
|
|
37
|
+
err.details = { connectionTimeout: this.timeout };
|
|
38
|
+
return err;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Maps a dependency's own expiry onto the shared `CONNECT_TIMEOUT` shape, so callers see one
|
|
43
|
+
* timeout error whichever layer noticed first. The original error is kept as `_err`. Anything
|
|
44
|
+
* that is not a timeout is returned unchanged.
|
|
45
|
+
*
|
|
46
|
+
* @param {Error} err Error raised by a dependency during a connection phase.
|
|
47
|
+
* @returns {Error} Either the normalized timeout error or the original error.
|
|
48
|
+
*/
|
|
49
|
+
normalize(err) {
|
|
50
|
+
if (!err || err.code === 'CONNECT_TIMEOUT') {
|
|
51
|
+
return err;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The `socks` client reports its own expiry as "Proxy connection timed out"
|
|
55
|
+
if (err.code !== 'ETIMEDOUT' && !/timed out/i.test(err.message || '')) {
|
|
56
|
+
return err;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let normalized = this.error();
|
|
60
|
+
normalized._err = err;
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Throws before a phase is started if the budget is already used up, so no work is begun
|
|
66
|
+
* that could only ever time out.
|
|
67
|
+
*/
|
|
68
|
+
check() {
|
|
69
|
+
if (!this.remaining()) {
|
|
70
|
+
throw this.error();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Races a phase against the remaining budget. The timer is always cleared, so a completed
|
|
76
|
+
* phase never leaves a pending timer behind.
|
|
77
|
+
*
|
|
78
|
+
* @param {Promise} promise Phase to run under the deadline.
|
|
79
|
+
* @returns {Promise<*>} Resolves with the phase result, rejects with `CONNECT_TIMEOUT`.
|
|
80
|
+
*/
|
|
81
|
+
async race(promise) {
|
|
82
|
+
this.check();
|
|
83
|
+
|
|
84
|
+
let timer = null;
|
|
85
|
+
try {
|
|
86
|
+
return await Promise.race([
|
|
87
|
+
promise,
|
|
88
|
+
new Promise((resolve, reject) => {
|
|
89
|
+
timer = setTimeout(() => reject(this.error()), this.remaining());
|
|
90
|
+
})
|
|
91
|
+
]);
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { ConnectionDeadline, CONNECT_TIMEOUT };
|
|
@@ -65,11 +65,14 @@ module.exports = async (response, options) => {
|
|
|
65
65
|
lastRespByte = String.fromCharCode(lastRespByte);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
// Add a space separator
|
|
69
|
-
// - The previous token was a LITERAL
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// -
|
|
68
|
+
// Add a space separator when:
|
|
69
|
+
// - The previous token was a LITERAL. Literal data ends exactly at its declared length, so
|
|
70
|
+
// a following token always needs an explicit separator, even though the last written byte
|
|
71
|
+
// is arbitrary literal content.
|
|
72
|
+
// - Otherwise: there is something written already (resp is not empty) and the last byte is
|
|
73
|
+
// not an opening delimiter ('(', '<' or '['), which suppresses the space.
|
|
74
|
+
// A sub-array element in a consecutive-list context never gets one (no space between
|
|
75
|
+
// adjacent lists).
|
|
73
76
|
if (lastType === 'LITERAL' || (!['(', '<', '['].includes(lastRespByte) && resp.length)) {
|
|
74
77
|
if (!options.subArray) {
|
|
75
78
|
resp.push(formatRespEntry(' '));
|