imapflow 2.2.5 → 2.2.7
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/CHANGELOG.md +17 -0
- package/dist/cjs/commands/append.js +10 -3
- package/dist/cjs/commands/authenticate.js +27 -18
- package/dist/cjs/commands/close.d.ts +12 -1
- package/dist/cjs/commands/close.js +4 -2
- package/dist/cjs/commands/copyuid-parser.js +16 -2
- package/dist/cjs/commands/delete.js +2 -1
- package/dist/cjs/commands/esearch-parser.js +8 -2
- package/dist/cjs/commands/fetch.js +35 -9
- package/dist/cjs/commands/id.js +8 -1
- package/dist/cjs/commands/idle.js +15 -5
- package/dist/cjs/commands/list.js +10 -1
- package/dist/cjs/commands/login.js +5 -1
- package/dist/cjs/commands/logout.js +7 -0
- package/dist/cjs/commands/namespace.js +7 -3
- package/dist/cjs/commands/quota.js +3 -1
- package/dist/cjs/commands/rename.js +2 -1
- package/dist/cjs/commands/select.js +5 -0
- package/dist/cjs/commands/status.js +6 -1
- package/dist/cjs/commands/store.d.ts +1 -1
- package/dist/cjs/commands/store.js +1 -2
- package/dist/cjs/download.js +82 -91
- package/dist/cjs/handler/imap-compiler.js +45 -29
- package/dist/cjs/handler/limits.d.ts +11 -0
- package/dist/cjs/handler/limits.js +16 -1
- package/dist/cjs/handler/parser-instance.d.ts +10 -0
- package/dist/cjs/handler/parser-instance.js +25 -10
- package/dist/cjs/handler/token-parser.js +162 -78
- package/dist/cjs/imap-flow.js +235 -89
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/cjs/proxy-connection.js +7 -7
- package/dist/cjs/search-compiler.js +37 -16
- package/dist/cjs/special-use.js +10 -5
- package/dist/cjs/tools.d.ts +10 -1
- package/dist/cjs/tools.js +30 -3
- package/dist/cjs/types.d.ts +19 -4
- package/dist/esm/commands/append.js +11 -4
- package/dist/esm/commands/authenticate.js +28 -19
- package/dist/esm/commands/close.d.ts +12 -1
- package/dist/esm/commands/close.js +5 -3
- package/dist/esm/commands/copyuid-parser.js +16 -2
- package/dist/esm/commands/delete.js +2 -1
- package/dist/esm/commands/esearch-parser.js +8 -2
- package/dist/esm/commands/fetch.js +35 -9
- package/dist/esm/commands/id.js +9 -2
- package/dist/esm/commands/idle.js +16 -6
- package/dist/esm/commands/list.js +10 -1
- package/dist/esm/commands/login.js +6 -2
- package/dist/esm/commands/logout.js +7 -0
- package/dist/esm/commands/namespace.js +7 -3
- package/dist/esm/commands/quota.js +3 -1
- package/dist/esm/commands/rename.js +2 -1
- package/dist/esm/commands/select.js +5 -0
- package/dist/esm/commands/status.js +7 -2
- package/dist/esm/commands/store.d.ts +1 -1
- package/dist/esm/commands/store.js +1 -2
- package/dist/esm/download.js +82 -91
- package/dist/esm/handler/imap-compiler.js +45 -29
- package/dist/esm/handler/limits.d.ts +11 -0
- package/dist/esm/handler/limits.js +14 -0
- package/dist/esm/handler/parser-instance.d.ts +10 -0
- package/dist/esm/handler/parser-instance.js +25 -10
- package/dist/esm/handler/token-parser.js +163 -79
- package/dist/esm/imap-flow.js +235 -89
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/dist/esm/proxy-connection.js +7 -7
- package/dist/esm/search-compiler.js +37 -16
- package/dist/esm/special-use.js +10 -5
- package/dist/esm/tools.d.ts +10 -1
- package/dist/esm/tools.js +29 -3
- package/dist/esm/types.d.ts +19 -4
- package/package.json +3 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { guardedPromise, hasCapability, logConnectionError, restampConnectionError, unrefTimer, clearTimer, getSelectedMailbox } from '../tools.js';
|
|
1
|
+
import { guardedPromise, hasCapability, isServerRefusal, logConnectionError, restampConnectionError, unrefTimer, clearTimer, getSelectedMailbox } from '../tools.js';
|
|
2
2
|
const NOOP_INTERVAL = 2 * 60 * 1000;
|
|
3
3
|
/**
|
|
4
4
|
* Marks the connection as idling on behalf of one session and returns a release function.
|
|
@@ -57,8 +57,11 @@ async function runIdle(connection) {
|
|
|
57
57
|
path: connection.mailbox && connection.mailbox.path,
|
|
58
58
|
cid: connection.id
|
|
59
59
|
});
|
|
60
|
-
|
|
60
|
+
// Marked before the write: write() closes the connection when the transport is
|
|
61
|
+
// already gone, and close() breaks IDLE through this very function, which
|
|
62
|
+
// would otherwise write DONE again from inside itself
|
|
61
63
|
doneSent = true;
|
|
64
|
+
connection.write('DONE');
|
|
62
65
|
releaseIdling();
|
|
63
66
|
if (connection.preCheck === ownPreCheck) {
|
|
64
67
|
connection.preCheck = false; // unset itself
|
|
@@ -124,7 +127,10 @@ async function runIdle(connection) {
|
|
|
124
127
|
// A tagged NO or BAD only means the server refused IDLE; the connection is still usable,
|
|
125
128
|
// so the waiters are released by the finally block below and their own commands run.
|
|
126
129
|
// Anything else (close, lost socket, parser failure) fails the waiters too.
|
|
127
|
-
let refusedByServer =
|
|
130
|
+
let refusedByServer = isServerRefusal(err);
|
|
131
|
+
if (refusedByServer) {
|
|
132
|
+
connection.skipIdle = true;
|
|
133
|
+
}
|
|
128
134
|
if (preCheckWaitQueue.length && !refusedByServer) {
|
|
129
135
|
// One error for the whole queue: every waiter failed at the same site, for the same
|
|
130
136
|
// reason. Built inside the guard so a teardown with nothing queued - the common case -
|
|
@@ -310,7 +316,7 @@ export default async function idle(connection, maxIdleTime) {
|
|
|
310
316
|
// If server supports IDLE (RFC 2177, folded into base IMAP4rev2), use it for
|
|
311
317
|
// real-time push notifications. Otherwise, fall back to periodic polling with
|
|
312
318
|
// NOOP/STATUS/SELECT.
|
|
313
|
-
if (hasCapability(connection, 'IDLE')) {
|
|
319
|
+
if (hasCapability(connection, 'IDLE') && !connection.skipIdle) {
|
|
314
320
|
let idleTimer;
|
|
315
321
|
let stillIdling = false;
|
|
316
322
|
// IDLE loop: runs IDLE, and if maxIdleTime is reached, breaks and restarts to keep the
|
|
@@ -334,13 +340,17 @@ export default async function idle(connection, maxIdleTime) {
|
|
|
334
340
|
}
|
|
335
341
|
let resp = await runIdle(connection);
|
|
336
342
|
clearTimeout(idleTimer);
|
|
337
|
-
|
|
343
|
+
// A restart only makes sense with nothing queued behind the break (a CLOSE, say; run()
|
|
344
|
+
// re-arms auto-IDLE once that command is done) and the mailbox still selected on a
|
|
345
|
+
// usable connection
|
|
346
|
+
const canRestart = stillIdling && !connection.requestQueue.length && !!getSelectedMailbox(connection) && connection.usable;
|
|
347
|
+
if (!canRestart) {
|
|
338
348
|
return resp;
|
|
339
349
|
}
|
|
340
350
|
stillIdling = false;
|
|
341
351
|
}
|
|
342
352
|
}
|
|
343
|
-
// Fallback for servers without IDLE support: poll at regular intervals using
|
|
353
|
+
// Fallback for servers without IDLE support, or that refused it: poll at regular intervals using
|
|
344
354
|
// NOOP (default), STATUS, or SELECT depending on missingIdleCommand config.
|
|
345
355
|
return runPollingFallback(connection, maxIdleTime);
|
|
346
356
|
}
|
|
@@ -375,6 +375,15 @@ export default async function list(connection, reference, mailbox, options) {
|
|
|
375
375
|
// Subscribed-only mailboxes that weren't in LIST are intentionally ignored
|
|
376
376
|
// (they may be phantom entries from old subscriptions to deleted mailboxes).
|
|
377
377
|
let runLsub = async () => {
|
|
378
|
+
// LIST has completed, so its entries are indexed once instead of searched per LSUB
|
|
379
|
+
// response, which was quadratic in the folder count. The first entry for a path wins,
|
|
380
|
+
// as with the linear search it replaces
|
|
381
|
+
let entriesByPath = new Map();
|
|
382
|
+
for (let entry of entries) {
|
|
383
|
+
if (!entriesByPath.has(entry.path)) {
|
|
384
|
+
entriesByPath.set(entry.path, entry);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
378
387
|
let response = await connection.exec('LSUB', [encodePath(connection, normalizedReference), encodePath(connection, normalizedMailbox)], {
|
|
379
388
|
untagged: {
|
|
380
389
|
LSUB: async (untagged) => {
|
|
@@ -400,7 +409,7 @@ export default async function list(connection, reference, mailbox, options) {
|
|
|
400
409
|
entry.parent = entry.delimiter ? entry.path.split(entry.delimiter) : [entry.path];
|
|
401
410
|
entry.name = entry.parent.pop();
|
|
402
411
|
// Merge LSUB data into existing LIST entry if found
|
|
403
|
-
let existing =
|
|
412
|
+
let existing = entriesByPath.get(entry.path);
|
|
404
413
|
if (existing) {
|
|
405
414
|
existing.subscribed = true;
|
|
406
415
|
// Merge any additional flags from LSUB into the LIST entry
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getStatusCode, getErrorText } from '../tools.js';
|
|
1
|
+
import { getStatusCode, getErrorText, isServerRefusal } from '../tools.js';
|
|
2
2
|
/**
|
|
3
3
|
* Authenticates user using the IMAP LOGIN command.
|
|
4
4
|
*
|
|
@@ -30,7 +30,11 @@ export default async function login(connection, username, password) {
|
|
|
30
30
|
if (errorCode) {
|
|
31
31
|
err.serverResponseCode = errorCode;
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
// Only a tagged NO/BAD is the server refusing the credentials; a lost connection or a
|
|
34
|
+
// timeout during LOGIN says nothing about them, and a caller may retry it
|
|
35
|
+
if (isServerRefusal(err)) {
|
|
36
|
+
err.authenticationFailed = true;
|
|
37
|
+
}
|
|
34
38
|
err.response = await getErrorText(err.response);
|
|
35
39
|
throw err;
|
|
36
40
|
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { clearTimer } from '../tools.js';
|
|
2
|
+
// How long to wait for the server to answer LOGOUT before closing the socket anyway. Without a
|
|
3
|
+
// bound of its own, an unanswered LOGOUT kept logout() pending until the socket timeout.
|
|
4
|
+
const LOGOUT_TIMEOUT = 10 * 1000;
|
|
1
5
|
/**
|
|
2
6
|
* Logs out the user and closes the connection.
|
|
3
7
|
*
|
|
@@ -16,6 +20,8 @@ export default async function logout(connection) {
|
|
|
16
20
|
return false;
|
|
17
21
|
}
|
|
18
22
|
let response;
|
|
23
|
+
// close() rejects the pending LOGOUT with NoConnection, which counts as a completed logout
|
|
24
|
+
let timer = setTimeout(() => connection.close(), LOGOUT_TIMEOUT);
|
|
19
25
|
try {
|
|
20
26
|
response = await connection.exec('LOGOUT');
|
|
21
27
|
return true;
|
|
@@ -33,6 +39,7 @@ export default async function logout(connection) {
|
|
|
33
39
|
// Set state to LOGOUT before closing to prevent any further commands from
|
|
34
40
|
// being queued. The socket is closed unconditionally in this finally block
|
|
35
41
|
// regardless of whether the LOGOUT command succeeded or failed.
|
|
42
|
+
clearTimer(timer);
|
|
36
43
|
connection.state = connection.states.LOGOUT;
|
|
37
44
|
if (response && typeof response.next === 'function') {
|
|
38
45
|
response.next();
|
|
@@ -31,7 +31,8 @@ export default async function namespace(connection) {
|
|
|
31
31
|
}
|
|
32
32
|
let response;
|
|
33
33
|
try {
|
|
34
|
-
|
|
34
|
+
// NIL personal namespaces and a missing NAMESPACE response leave the defaults in place
|
|
35
|
+
let map = { personal: [], other: false, shared: false };
|
|
35
36
|
response = await connection.exec('NAMESPACE', false, {
|
|
36
37
|
untagged: {
|
|
37
38
|
// The NAMESPACE response (RFC 2342) contains exactly three sections:
|
|
@@ -43,19 +44,22 @@ export default async function namespace(connection) {
|
|
|
43
44
|
if (!untagged.attributes || !untagged.attributes.length) {
|
|
44
45
|
return;
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
+
// NIL personal namespaces are legal (RFC 2342 section 5, e.g. after an anonymous login)
|
|
48
|
+
map.personal = getNamsepaceInfo(untagged.attributes[0]) || [];
|
|
47
49
|
map.other = getNamsepaceInfo(untagged.attributes[1]);
|
|
48
50
|
map.shared = getNamsepaceInfo(untagged.attributes[2]);
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
});
|
|
54
|
+
// Release the response before touching the parsed data, so nothing below can leave the
|
|
55
|
+
// reader parked behind this command
|
|
56
|
+
response.next();
|
|
52
57
|
connection.namespaces = map;
|
|
53
58
|
// make sure that we have the first personal namespace always set
|
|
54
59
|
if (!connection.namespaces.personal[0]) {
|
|
55
60
|
connection.namespaces.personal[0] = { prefix: '', delimiter: '.' };
|
|
56
61
|
}
|
|
57
62
|
connection.namespaces.personal[0].prefix = connection.namespaces.personal[0].prefix || '';
|
|
58
|
-
response.next();
|
|
59
63
|
connection.namespace = connection.namespaces.personal[0];
|
|
60
64
|
return connection.namespace;
|
|
61
65
|
}
|
|
@@ -11,7 +11,9 @@ export default async function quota(connection, path) {
|
|
|
11
11
|
// nothing to do here
|
|
12
12
|
return;
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
// An RFC 9208 server advertises its QUOTA=RES-* resource types and does not have to list
|
|
15
|
+
// the bare RFC 2087 QUOTA token as well
|
|
16
|
+
if (!connection.capabilities.has('QUOTA') && ![...connection.capabilities.keys()].some(capability => capability.startsWith('QUOTA=RES-'))) {
|
|
15
17
|
return false;
|
|
16
18
|
}
|
|
17
19
|
path = normalizePath(connection, path);
|
|
@@ -21,7 +21,8 @@ export default async function rename(connection, path, newPath) {
|
|
|
21
21
|
// as IMAP servers will not rename an active mailbox.
|
|
22
22
|
let selected = getSelectedMailbox(connection);
|
|
23
23
|
if (selected && selected.path === path) {
|
|
24
|
-
|
|
24
|
+
// UNSELECT where possible, CLOSE would expunge messages flagged \Deleted
|
|
25
|
+
await connection.run('CLOSE', { unselect: true });
|
|
25
26
|
}
|
|
26
27
|
let response;
|
|
27
28
|
try {
|
|
@@ -226,6 +226,11 @@ export default async function select(connection, pathInput, options) {
|
|
|
226
226
|
if (!currentMailbox || currentMailbox.path !== path) {
|
|
227
227
|
emitSafe(connection, 'mailboxOpen', connection.mailbox);
|
|
228
228
|
}
|
|
229
|
+
else if (typeof map.exists === 'number' && map.exists !== currentMailbox.exists) {
|
|
230
|
+
// A re-SELECT of the open mailbox (the SELECT polling fallback) gets its EXISTS here
|
|
231
|
+
// instead of in the global handler, so it is reported the same way
|
|
232
|
+
emitSafe(connection, 'exists', { path, count: map.exists, prevCount: currentMailbox.exists });
|
|
233
|
+
}
|
|
229
234
|
response.next();
|
|
230
235
|
return map;
|
|
231
236
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { encodePath, normalizePath, buildStatusQueryAttributes, isRev2Active, isAuthenticatedState, emitSafe, getSelectedMailbox } from '../tools.js';
|
|
1
|
+
import { encodePath, normalizePath, buildStatusQueryAttributes, isRev2Active, isAuthenticatedState, emitSafe, getSelectedMailbox, logConnectionError } from '../tools.js';
|
|
2
2
|
import { parseStatusList } from './status-fields.js';
|
|
3
3
|
// STATUS fields that also refresh the live mailbox state when the queried mailbox is the
|
|
4
4
|
// currently selected one. Keyed by the output property name parseStatusList() reports.
|
|
@@ -89,7 +89,12 @@ export default async function status(connection, path, query) {
|
|
|
89
89
|
// Not a deadlock, and only reachable when the server rejects the STATUS, but a polled
|
|
90
90
|
// STATUS of a missing folder ends the poll early.
|
|
91
91
|
if (err.responseStatus === 'NO') {
|
|
92
|
-
|
|
92
|
+
// A failing probe (lost connection, throttling) answers nothing about the mailbox,
|
|
93
|
+
// so STATUS then fails the same way as any other failed STATUS
|
|
94
|
+
let folders = await connection.run('LIST', '', path, { listOnly: true }).catch((listErr) => {
|
|
95
|
+
logConnectionError(connection, 'Failed to check if the mailbox exists', listErr);
|
|
96
|
+
return false;
|
|
97
|
+
});
|
|
93
98
|
if (folders && !folders.length) {
|
|
94
99
|
let error = new Error(`Mailbox doesn't exist: ${path}`);
|
|
95
100
|
error.code = 'NotFound';
|
|
@@ -16,4 +16,4 @@ export interface StoreCommandOptions extends StoreOptions {
|
|
|
16
16
|
* @param options - Store options
|
|
17
17
|
* @returns True on success, false on failure or if nothing to do
|
|
18
18
|
*/
|
|
19
|
-
export default function store(connection: ImapFlow, range: string, flags: string | string[], options
|
|
19
|
+
export default function store(connection: ImapFlow, range: string, flags: string | string[], options?: StoreCommandOptions | undefined): Promise<boolean>;
|
|
@@ -9,13 +9,12 @@ import { formatFlag, canUseFlag, reportCommandError, getSelectedMailbox } from '
|
|
|
9
9
|
* @returns True on success, false on failure or if nothing to do
|
|
10
10
|
*/
|
|
11
11
|
export default async function store(connection, range, flags, options) {
|
|
12
|
+
options = options || {};
|
|
12
13
|
let mailbox = getSelectedMailbox(connection);
|
|
13
14
|
if (!mailbox || !range || (options.useLabels && !connection.capabilities.has('X-GM-EXT-1'))) {
|
|
14
15
|
// nothing to do here
|
|
15
16
|
return false;
|
|
16
17
|
}
|
|
17
|
-
/* c8 ignore next */ // options.useLabels is dereferenced in the guard above, so options is always defined here
|
|
18
|
-
options = options || {};
|
|
19
18
|
// Build the IMAP STORE operation name. The format is:
|
|
20
19
|
// [+|-]FLAGS[.SILENT] or [+|-]X-GM-LABELS
|
|
21
20
|
// Where: no prefix = replace all, + = add, - = remove
|
package/dist/esm/download.js
CHANGED
|
@@ -8,6 +8,9 @@ import libbase64 from 'libbase64';
|
|
|
8
8
|
import { Headers } from '@zone-eu/mailsplit';
|
|
9
9
|
import FlowedDecoder from '@zone-eu/mailsplit/lib/flowed-decoder.js';
|
|
10
10
|
import { LimitedPassthrough, normalizeByteLimit } from './limited-passthrough.js';
|
|
11
|
+
// What a wait on the head stream listens for: it can take more input, it failed, or it went
|
|
12
|
+
// away (a consumer destroying it closes it without a 'drain')
|
|
13
|
+
const DRAIN_WAIT_EVENTS = ['drain', 'error', 'close'];
|
|
11
14
|
import { getDecoder, isUnsafeKey } from './tools.js';
|
|
12
15
|
/**
|
|
13
16
|
* Implements ImapFlow.download(), see its documentation
|
|
@@ -47,7 +50,8 @@ export async function downloadMessage(client, range, part, options) {
|
|
|
47
50
|
range = uid;
|
|
48
51
|
downloadOptions.uid = true;
|
|
49
52
|
}
|
|
50
|
-
|
|
53
|
+
// bodyStructure is unset when the server sent BODYSTRUCTURE NIL
|
|
54
|
+
if (!response.bodyStructure?.childNodes) {
|
|
51
55
|
// single text message
|
|
52
56
|
part = 'TEXT';
|
|
53
57
|
}
|
|
@@ -290,11 +294,54 @@ export async function downloadMessage(client, range, part, options) {
|
|
|
290
294
|
// at the bound still gets its terminating chunk. Infinity when the server reported no
|
|
291
295
|
// size, which leaves the loop bounded by maxBytes alone.
|
|
292
296
|
let maxTotalBytes = normalizeByteLimit(meta.expectedSize ? meta.expectedSize * 2 + chunkSize : 0);
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
297
|
+
// Resolves once the head stream can take more input, rejects when it fails, and resolves
|
|
298
|
+
// when it goes away. finish() is the listener itself: 'drain' and 'close' emit no arguments,
|
|
299
|
+
// 'error' emits the error, and removal needs no separate handler references. It removes
|
|
300
|
+
// only the listeners this wait installed - removeAllListeners('error') also took off the
|
|
301
|
+
// forwarder pipeStage() attached to the head stream when the pipeline was built, and the
|
|
302
|
+
// head must keep that forwarder for the life of the download or a chunk failure has nowhere
|
|
303
|
+
// to go.
|
|
304
|
+
let waitForDrain = () => new Promise((resolve, reject) => {
|
|
305
|
+
const finish = (err) => {
|
|
306
|
+
for (let event of DRAIN_WAIT_EVENTS) {
|
|
307
|
+
stream.removeListener(event, finish);
|
|
308
|
+
}
|
|
309
|
+
/* c8 ignore next 2 */ // stream error during a backpressure drain wait is timing-dependent
|
|
310
|
+
if (err) {
|
|
311
|
+
reject(err);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
resolve();
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
for (let event of DRAIN_WAIT_EVENTS) {
|
|
318
|
+
stream.once(event, finish);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
// Writes a chunk and waits out the backpressure. A base64 decoder defers its write callback,
|
|
322
|
+
// so a chunk the size of its buffer is never taken synchronously and the head chunk waits
|
|
323
|
+
// here as much as any other. A stream failure during the wait is thrown unless the download
|
|
324
|
+
// was already aborted (the consumer destroyed the stream).
|
|
325
|
+
let writeAndDrain = async (chunk) => {
|
|
326
|
+
if (writeChunk(chunk) !== false) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
await waitForDrain();
|
|
331
|
+
/* c8 ignore next 5 */ // re-throw path only triggers on a stream error mid-drain, which is timing-dependent
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
if (!fetchAborted) {
|
|
335
|
+
throw err;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
// Writes the head chunk, then fetches the remaining chunks in a loop, writing each to the
|
|
340
|
+
// decoder stream. Stops when the server returns a short chunk (< chunkSize), answers with
|
|
341
|
+
// more than the requested window, the byte limiter is satisfied, or the consumer destroys
|
|
342
|
+
// the output stream. Throws when the ceiling above is crossed.
|
|
343
|
+
let fetchAllParts = async (head) => {
|
|
344
|
+
await writeAndDrain(head);
|
|
298
345
|
while (hasMore && !isLimited() && !fetchAborted) {
|
|
299
346
|
if (processed >= maxTotalBytes) {
|
|
300
347
|
// Loud on purpose. Everything written downstream by this point holds
|
|
@@ -307,7 +354,9 @@ export async function downloadMessage(client, range, part, options) {
|
|
|
307
354
|
throw err;
|
|
308
355
|
}
|
|
309
356
|
let { response, chunk } = await getNextPart();
|
|
310
|
-
|
|
357
|
+
// A consumer that gave up while the chunk was in flight: its 'close' may still be a
|
|
358
|
+
// tick away from setting fetchAborted, so the stream's own flag is checked as well
|
|
359
|
+
if (fetchAborted || output.destroyed) {
|
|
311
360
|
break;
|
|
312
361
|
}
|
|
313
362
|
if (response === false) {
|
|
@@ -322,48 +371,7 @@ export async function downloadMessage(client, range, part, options) {
|
|
|
322
371
|
if (!chunk) {
|
|
323
372
|
break;
|
|
324
373
|
}
|
|
325
|
-
|
|
326
|
-
if (writeChunk(chunk) === false) {
|
|
327
|
-
// Wait for drain event before continuing
|
|
328
|
-
try {
|
|
329
|
-
await new Promise((resolve, reject) => {
|
|
330
|
-
// finish() is the listener itself, as settle() is for the TLS upgrade:
|
|
331
|
-
// 'drain' and 'close' emit no arguments, 'error' emits the error, and
|
|
332
|
-
// removal needs no separate handler references. It removes only the
|
|
333
|
-
// three listeners this wait installed - removeAllListeners('error')
|
|
334
|
-
// also took off the forwarder pipeStage() attached to the head stream
|
|
335
|
-
// when the pipeline was built, and the head must keep that forwarder
|
|
336
|
-
// for the life of the download or a chunk failure has nowhere to go.
|
|
337
|
-
const finish = (err) => {
|
|
338
|
-
for (let event of ['drain', 'error', 'close']) {
|
|
339
|
-
stream.removeListener(event, finish);
|
|
340
|
-
}
|
|
341
|
-
/* c8 ignore next 2 */ // stream error during a backpressure drain wait is timing-dependent
|
|
342
|
-
if (err) {
|
|
343
|
-
reject(err);
|
|
344
|
-
}
|
|
345
|
-
else {
|
|
346
|
-
resolve();
|
|
347
|
-
}
|
|
348
|
-
};
|
|
349
|
-
stream.once('drain', finish);
|
|
350
|
-
stream.once('error', finish);
|
|
351
|
-
stream.once('close', finish);
|
|
352
|
-
});
|
|
353
|
-
/* c8 ignore start */ // re-throw path only triggers on a stream error mid-drain, which is timing-dependent
|
|
354
|
-
}
|
|
355
|
-
catch (err) {
|
|
356
|
-
// Re-throw only if not aborted
|
|
357
|
-
if (!fetchAborted) {
|
|
358
|
-
throw err;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
/* c8 ignore stop */
|
|
362
|
-
// Check if we should abort after waiting
|
|
363
|
-
if (fetchAborted) {
|
|
364
|
-
break;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
374
|
+
await writeAndDrain(chunk);
|
|
367
375
|
}
|
|
368
376
|
};
|
|
369
377
|
// A download is a sequence of chunk FETCHes with a backpressure wait in between. Those
|
|
@@ -382,13 +390,10 @@ export async function downloadMessage(client, range, part, options) {
|
|
|
382
390
|
client.autoidle();
|
|
383
391
|
}
|
|
384
392
|
};
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
// value before streaming begins.
|
|
390
|
-
let runFetchAllParts = () => {
|
|
391
|
-
fetchAllParts()
|
|
393
|
+
// Runs the download pipeline with the head chunk fetched above (for its metadata): it is
|
|
394
|
+
// written to the decoder stream and the remaining chunks follow
|
|
395
|
+
let runFetchAllParts = (head) => {
|
|
396
|
+
fetchAllParts(head)
|
|
392
397
|
.catch(err => {
|
|
393
398
|
if (!fetchAborted && stream && !stream.destroyed) {
|
|
394
399
|
stream.emit('error', err);
|
|
@@ -420,36 +425,8 @@ export async function downloadMessage(client, range, part, options) {
|
|
|
420
425
|
// for a routine-looking connection code.
|
|
421
426
|
.catch(err => client.log.error({ msg: 'Failed to fail the download stream', err, cid: client.id }));
|
|
422
427
|
};
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
try {
|
|
426
|
-
writeResult = writeChunk(chunk);
|
|
427
|
-
}
|
|
428
|
-
catch (err) {
|
|
429
|
-
stream.emit('error', err);
|
|
430
|
-
finishDownload();
|
|
431
|
-
/* c8 ignore next 3 */ // emitting the error above triggers cleanup (fetchAborted=true), so this end() guard is already false here
|
|
432
|
-
if (!fetchAborted && stream && !stream.destroyed) {
|
|
433
|
-
stream.end();
|
|
434
|
-
}
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
/* c8 ignore next 9 */ // `stream` is piped to the limiter before this runs, so the head write drains synchronously and always returns true (verified for chunkSize up to 8MB); the drain-wait branch is unreachable
|
|
438
|
-
if (!writeResult) {
|
|
439
|
-
// Initial chunk filled the buffer, wait for drain
|
|
440
|
-
stream.once('drain', () => {
|
|
441
|
-
if (!fetchAborted) {
|
|
442
|
-
runFetchAllParts();
|
|
443
|
-
}
|
|
444
|
-
else {
|
|
445
|
-
finishDownload();
|
|
446
|
-
}
|
|
447
|
-
});
|
|
448
|
-
}
|
|
449
|
-
else {
|
|
450
|
-
runFetchAllParts();
|
|
451
|
-
}
|
|
452
|
-
});
|
|
428
|
+
// Deferred so the caller gets the {meta, content} return value before streaming begins
|
|
429
|
+
setImmediate(() => runFetchAllParts(chunk));
|
|
453
430
|
return {
|
|
454
431
|
meta,
|
|
455
432
|
content: output
|
|
@@ -469,14 +446,16 @@ export async function downloadMessageParts(client, range, parts, options) {
|
|
|
469
446
|
// no mailbox selected, nothing to do
|
|
470
447
|
return {};
|
|
471
448
|
}
|
|
472
|
-
let downloadOptions =
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
449
|
+
let downloadOptions = options || {};
|
|
450
|
+
// Asked as a partial fetch so at most maxBytes of each part crosses the wire, and enforced
|
|
451
|
+
// again on the answer for servers that ignore the partial specifier
|
|
452
|
+
let maxBytes = normalizeByteLimit(downloadOptions.maxBytes);
|
|
476
453
|
let query = { bodyParts: [] };
|
|
477
454
|
for (let part of parts) {
|
|
478
455
|
query.bodyParts.push(part + '.mime');
|
|
479
|
-
|
|
456
|
+
// The partial specifier carries a 32-bit length (RFC 9051 "number"), so a cap beyond
|
|
457
|
+
// that is applied on the answer alone
|
|
458
|
+
query.bodyParts.push(maxBytes > 0xffffffff ? part : { key: part, start: 0, maxLength: maxBytes });
|
|
480
459
|
}
|
|
481
460
|
let response = await client.fetchOne(range, query, downloadOptions);
|
|
482
461
|
if (!response || !response.bodyParts) {
|
|
@@ -494,6 +473,18 @@ export async function downloadMessageParts(client, range, parts, options) {
|
|
|
494
473
|
if (keyParts.length === 1) {
|
|
495
474
|
// content
|
|
496
475
|
let key = keyParts[0];
|
|
476
|
+
if (content.length > maxBytes) {
|
|
477
|
+
// The server ignored the partial specifier and sent the whole part, the quirk
|
|
478
|
+
// download() tolerates the same way: keep what was asked for
|
|
479
|
+
client.log.warn({
|
|
480
|
+
msg: 'Server returned more than the requested window, truncating the part',
|
|
481
|
+
part: key,
|
|
482
|
+
maxBytes,
|
|
483
|
+
received: content.length,
|
|
484
|
+
cid: client.id
|
|
485
|
+
});
|
|
486
|
+
content = content.subarray(0, maxBytes);
|
|
487
|
+
}
|
|
497
488
|
if (!data[key]) {
|
|
498
489
|
data[key] = { content };
|
|
499
490
|
}
|