imapflow 1.7.8 → 2.0.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/CHANGELOG.md +27 -0
- package/README.md +8 -2
- package/dist/cjs/charsets.d.ts +1 -0
- package/dist/cjs/charsets.js +294 -0
- package/dist/cjs/commands/append.d.ts +22 -0
- package/dist/cjs/commands/append.js +151 -0
- package/dist/cjs/commands/authenticate.d.ts +24 -0
- package/dist/cjs/commands/authenticate.js +223 -0
- package/dist/cjs/commands/capability.d.ts +8 -0
- package/dist/cjs/commands/capability.js +32 -0
- package/dist/cjs/commands/close.d.ts +8 -0
- package/dist/cjs/commands/close.js +39 -0
- package/dist/cjs/commands/compress.d.ts +8 -0
- package/dist/cjs/commands/compress.js +56 -0
- package/dist/cjs/commands/copy.d.ts +13 -0
- package/dist/cjs/commands/copy.js +44 -0
- package/dist/cjs/commands/copyuid-parser.d.ts +11 -0
- package/dist/cjs/commands/copyuid-parser.js +32 -0
- package/dist/cjs/commands/create.d.ts +11 -0
- package/dist/cjs/commands/create.js +80 -0
- package/dist/cjs/commands/delete.d.ts +11 -0
- package/dist/cjs/commands/delete.js +40 -0
- package/dist/cjs/commands/enable.d.ts +9 -0
- package/dist/cjs/commands/enable.js +61 -0
- package/dist/cjs/commands/esearch-parser.d.ts +17 -0
- package/dist/cjs/commands/esearch-parser.js +91 -0
- package/dist/cjs/commands/expunge.d.ts +12 -0
- package/dist/cjs/commands/expunge.js +60 -0
- package/dist/cjs/commands/fetch.d.ts +30 -0
- package/dist/cjs/commands/fetch.js +241 -0
- package/dist/cjs/commands/id.d.ts +10 -0
- package/dist/cjs/commands/id.js +80 -0
- package/dist/cjs/commands/idle.d.ts +9 -0
- package/dist/cjs/commands/idle.js +347 -0
- package/dist/cjs/commands/list.d.ts +16 -0
- package/dist/cjs/commands/list.js +520 -0
- package/dist/cjs/commands/login.d.ts +11 -0
- package/dist/cjs/commands/login.js +42 -0
- package/dist/cjs/commands/logout.d.ts +8 -0
- package/dist/cjs/commands/logout.js +47 -0
- package/dist/cjs/commands/move.d.ts +13 -0
- package/dist/cjs/commands/move.js +57 -0
- package/dist/cjs/commands/namespace.d.ts +25 -0
- package/dist/cjs/commands/namespace.js +139 -0
- package/dist/cjs/commands/noop.d.ts +8 -0
- package/dist/cjs/commands/noop.js +22 -0
- package/dist/cjs/commands/quota.d.ts +10 -0
- package/dist/cjs/commands/quota.js +119 -0
- package/dist/cjs/commands/rename.d.ts +12 -0
- package/dist/cjs/commands/rename.js +48 -0
- package/dist/cjs/commands/search.d.ts +15 -0
- package/dist/cjs/commands/search.js +228 -0
- package/dist/cjs/commands/select.d.ts +25 -0
- package/dist/cjs/commands/select.js +250 -0
- package/dist/cjs/commands/starttls.d.ts +8 -0
- package/dist/cjs/commands/starttls.js +30 -0
- package/dist/cjs/commands/status-fields.d.ts +14 -0
- package/dist/cjs/commands/status-fields.js +61 -0
- package/dist/cjs/commands/status.d.ts +12 -0
- package/dist/cjs/commands/status.js +108 -0
- package/dist/cjs/commands/store.d.ts +19 -0
- package/dist/cjs/commands/store.js +93 -0
- package/dist/cjs/commands/subscribe.d.ts +9 -0
- package/dist/cjs/commands/subscribe.js +31 -0
- package/dist/cjs/commands/unsubscribe.d.ts +9 -0
- package/dist/cjs/commands/unsubscribe.js +31 -0
- package/dist/cjs/connection-deadline.d.ts +49 -0
- package/dist/cjs/connection-deadline.js +91 -0
- package/dist/cjs/errors.d.ts +83 -0
- package/dist/cjs/errors.js +13 -0
- package/dist/cjs/handler/imap-compiler.d.ts +24 -0
- package/dist/cjs/handler/imap-compiler.js +285 -0
- package/dist/cjs/handler/imap-formal-syntax.d.ts +28 -0
- package/dist/cjs/handler/imap-formal-syntax.js +121 -0
- package/dist/cjs/handler/imap-handler.d.ts +9 -0
- package/dist/cjs/handler/imap-handler.js +10 -0
- package/dist/cjs/handler/imap-parser.d.ts +16 -0
- package/dist/cjs/handler/imap-parser.js +90 -0
- package/dist/cjs/handler/imap-stream.d.ts +181 -0
- package/dist/cjs/handler/imap-stream.js +446 -0
- package/dist/cjs/handler/limits.d.ts +25 -0
- package/dist/cjs/handler/limits.js +51 -0
- package/dist/cjs/handler/parser-instance.d.ts +68 -0
- package/dist/cjs/handler/parser-instance.js +223 -0
- package/dist/cjs/handler/token-parser.d.ts +91 -0
- package/dist/cjs/handler/token-parser.js +673 -0
- package/dist/cjs/handler/types.d.ts +91 -0
- package/dist/cjs/handler/types.js +4 -0
- package/dist/cjs/imap-commands.d.ts +16 -0
- package/dist/cjs/imap-commands.js +74 -0
- package/dist/cjs/imap-flow.d.ts +676 -0
- package/dist/cjs/imap-flow.js +3956 -0
- package/dist/cjs/jp-decoder.d.ts +12 -0
- package/dist/cjs/jp-decoder.js +79 -0
- package/dist/cjs/limited-passthrough.d.ts +25 -0
- package/dist/cjs/limited-passthrough.js +54 -0
- package/dist/cjs/logger.d.ts +3 -0
- package/dist/cjs/logger.js +11 -0
- package/dist/cjs/package-info.d.ts +3 -0
- package/dist/cjs/package-info.js +7 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/proxy-connection.d.ts +33 -0
- package/dist/cjs/proxy-connection.js +392 -0
- package/dist/cjs/search-compiler.d.ts +34 -0
- package/dist/cjs/search-compiler.js +476 -0
- package/dist/cjs/special-use.d.ts +22 -0
- package/dist/cjs/special-use.js +911 -0
- package/dist/cjs/tools.d.ts +427 -0
- package/dist/cjs/tools.js +1496 -0
- package/{lib/imap-flow.d.ts → dist/cjs/types.d.ts} +387 -517
- package/dist/cjs/types.js +5 -0
- package/dist/esm/charsets.d.ts +1 -0
- package/{lib → dist/esm}/charsets.js +1 -6
- package/dist/esm/commands/append.d.ts +22 -0
- package/{lib → dist/esm}/commands/append.js +22 -52
- package/dist/esm/commands/authenticate.d.ts +24 -0
- package/{lib → dist/esm}/commands/authenticate.js +62 -87
- package/dist/esm/commands/capability.d.ts +8 -0
- package/{lib → dist/esm}/commands/capability.js +6 -9
- package/dist/esm/commands/close.d.ts +8 -0
- package/{lib → dist/esm}/commands/close.js +6 -10
- package/dist/esm/commands/compress.d.ts +8 -0
- package/{lib → dist/esm}/commands/compress.js +7 -11
- package/dist/esm/commands/copy.d.ts +13 -0
- package/{lib → dist/esm}/commands/copy.js +12 -20
- package/dist/esm/commands/copyuid-parser.d.ts +11 -0
- package/{lib → dist/esm}/commands/copyuid-parser.js +9 -15
- package/dist/esm/commands/create.d.ts +11 -0
- package/{lib → dist/esm}/commands/create.js +13 -27
- package/dist/esm/commands/delete.d.ts +11 -0
- package/{lib → dist/esm}/commands/delete.js +9 -14
- package/dist/esm/commands/enable.d.ts +9 -0
- package/{lib → dist/esm}/commands/enable.js +23 -30
- package/dist/esm/commands/esearch-parser.d.ts +17 -0
- package/dist/esm/commands/esearch-parser.js +88 -0
- package/dist/esm/commands/expunge.d.ts +12 -0
- package/{lib → dist/esm}/commands/expunge.js +17 -22
- package/dist/esm/commands/fetch.d.ts +30 -0
- package/{lib → dist/esm}/commands/fetch.js +32 -64
- package/dist/esm/commands/id.d.ts +10 -0
- package/{lib → dist/esm}/commands/id.js +17 -23
- package/dist/esm/commands/idle.d.ts +9 -0
- package/{lib → dist/esm}/commands/idle.js +47 -81
- package/dist/esm/commands/list.d.ts +16 -0
- package/{lib → dist/esm}/commands/list.js +60 -123
- package/dist/esm/commands/login.d.ts +11 -0
- package/{lib → dist/esm}/commands/login.js +10 -15
- package/dist/esm/commands/logout.d.ts +8 -0
- package/{lib → dist/esm}/commands/logout.js +9 -11
- package/dist/esm/commands/move.d.ts +13 -0
- package/{lib → dist/esm}/commands/move.js +13 -21
- package/dist/esm/commands/namespace.d.ts +25 -0
- package/{lib → dist/esm}/commands/namespace.js +34 -44
- package/dist/esm/commands/noop.d.ts +8 -0
- package/{lib → dist/esm}/commands/noop.js +6 -7
- package/dist/esm/commands/quota.d.ts +10 -0
- package/{lib → dist/esm}/commands/quota.js +18 -36
- package/dist/esm/commands/rename.d.ts +12 -0
- package/{lib → dist/esm}/commands/rename.js +10 -15
- package/dist/esm/commands/search.d.ts +15 -0
- package/{lib → dist/esm}/commands/search.js +36 -135
- package/dist/esm/commands/select.d.ts +25 -0
- package/{lib → dist/esm}/commands/select.js +33 -64
- package/dist/esm/commands/starttls.d.ts +8 -0
- package/{lib → dist/esm}/commands/starttls.js +6 -8
- package/dist/esm/commands/status-fields.d.ts +14 -0
- package/{lib → dist/esm}/commands/status-fields.js +5 -16
- package/dist/esm/commands/status.d.ts +12 -0
- package/{lib → dist/esm}/commands/status.js +18 -29
- package/dist/esm/commands/store.d.ts +19 -0
- package/{lib → dist/esm}/commands/store.js +24 -37
- package/dist/esm/commands/subscribe.d.ts +9 -0
- package/{lib → dist/esm}/commands/subscribe.js +8 -12
- package/dist/esm/commands/unsubscribe.d.ts +9 -0
- package/{lib → dist/esm}/commands/unsubscribe.js +8 -12
- package/dist/esm/connection-deadline.d.ts +49 -0
- package/{lib → dist/esm}/connection-deadline.js +14 -25
- package/dist/esm/errors.d.ts +83 -0
- package/dist/esm/errors.js +9 -0
- package/dist/esm/handler/imap-compiler.d.ts +24 -0
- package/{lib → dist/esm}/handler/imap-compiler.js +22 -80
- package/dist/esm/handler/imap-formal-syntax.d.ts +28 -0
- package/dist/esm/handler/imap-formal-syntax.js +117 -0
- package/dist/esm/handler/imap-handler.d.ts +9 -0
- package/dist/esm/handler/imap-handler.js +9 -0
- package/dist/esm/handler/imap-parser.d.ts +16 -0
- package/{lib → dist/esm}/handler/imap-parser.js +31 -44
- package/dist/esm/handler/imap-stream.d.ts +181 -0
- package/{lib → dist/esm}/handler/imap-stream.js +29 -121
- package/dist/esm/handler/limits.d.ts +25 -0
- package/{lib → dist/esm}/handler/limits.js +13 -22
- package/dist/esm/handler/parser-instance.d.ts +68 -0
- package/{lib → dist/esm}/handler/parser-instance.js +19 -47
- package/dist/esm/handler/token-parser.d.ts +91 -0
- package/{lib → dist/esm}/handler/token-parser.js +71 -155
- package/dist/esm/handler/types.d.ts +91 -0
- package/dist/esm/handler/types.js +3 -0
- package/dist/esm/imap-commands.d.ts +16 -0
- package/dist/esm/imap-commands.js +67 -0
- package/dist/esm/imap-flow.d.ts +676 -0
- package/{lib → dist/esm}/imap-flow.js +769 -1790
- package/dist/esm/jp-decoder.d.ts +12 -0
- package/{lib → dist/esm}/jp-decoder.js +6 -21
- package/dist/esm/limited-passthrough.d.ts +25 -0
- package/{lib → dist/esm}/limited-passthrough.js +7 -20
- package/dist/esm/logger.d.ts +3 -0
- package/dist/esm/logger.js +4 -0
- package/dist/esm/package-info.d.ts +3 -0
- package/dist/esm/package-info.js +4 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/proxy-connection.d.ts +33 -0
- package/{lib → dist/esm}/proxy-connection.js +56 -127
- package/dist/esm/search-compiler.d.ts +34 -0
- package/{lib → dist/esm}/search-compiler.js +54 -110
- package/dist/esm/special-use.d.ts +22 -0
- package/dist/esm/special-use.js +907 -0
- package/dist/esm/tools.d.ts +427 -0
- package/dist/esm/tools.js +1446 -0
- package/dist/esm/types.d.ts +828 -0
- package/dist/esm/types.js +4 -0
- package/package.json +60 -20
- package/.gitattributes +0 -1
- package/.github/CODE_OF_CONDUCT.md +0 -76
- package/.github/FUNDING.yml +0 -4
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -40
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
- package/.github/contributing.md +0 -17
- package/.github/workflows/release.yaml +0 -36
- package/.github/workflows/stale.yml +0 -29
- package/.github/workflows/test.yml +0 -51
- package/.ncurc.js +0 -4
- package/.prettierignore +0 -4
- package/.prettierrc.js +0 -8
- package/.release-please-manifest.json +0 -3
- package/CLAUDE.md +0 -104
- package/Gruntfile.js +0 -23
- package/eslint.config.js +0 -45
- package/lib/handler/imap-formal-syntax.js +0 -189
- package/lib/handler/imap-handler.js +0 -17
- package/lib/imap-commands.js +0 -45
- package/lib/logger.js +0 -5
- package/lib/special-use.js +0 -923
- package/lib/tools.js +0 -1612
- package/release-please-config.json +0 -10
- package/test/authentication-test.js +0 -101
- package/test/auto-idle-test.js +0 -470
- package/test/bodystructure-test.js +0 -899
- package/test/charsets-test.js +0 -161
- package/test/commands-branches-test.js +0 -1095
- package/test/commands-integration-test.js +0 -11124
- package/test/commands-test.js +0 -73
- package/test/connection-edge-cases-test.js +0 -1828
- package/test/connection-test.js +0 -162
- package/test/copyuid-parser-test.js +0 -173
- package/test/fetch-generator-test.js +0 -218
- package/test/fixtures/fake-timers.js +0 -115
- package/test/fixtures/serialized-mimetorture.js +0 -2738
- package/test/fixtures/test-client.js +0 -101
- package/test/fixtures/test-tls.js +0 -8
- package/test/handler-branches-test.js +0 -310
- package/test/idle-polling-test.js +0 -518
- package/test/imap-compiler-test.js +0 -809
- package/test/imap-flow-compress-test.js +0 -166
- package/test/imap-flow-coverage-test.js +0 -612
- package/test/imap-flow-fetch-download-test.js +0 -909
- package/test/imap-flow-internals-test.js +0 -725
- package/test/imap-flow-methods-test.js +0 -889
- package/test/imap-flow-proxy-paths-test.js +0 -366
- package/test/imap-flow-secure-test.js +0 -573
- package/test/imap-flow-server-test.js +0 -1474
- package/test/imap-formal-syntax-test.js +0 -293
- package/test/imap-parser-test.js +0 -1474
- package/test/imap-stream-edge-cases-test.js +0 -666
- package/test/imap-stream-test.js +0 -177
- package/test/imapflow-test.js +0 -258
- package/test/integration/README.md +0 -52
- package/test/integration/dovecot-test.conf +0 -27
- package/test/integration/rev2-live-test.js +0 -431
- package/test/integration/run-rev2-tests.sh +0 -75
- package/test/integration-test.js +0 -83
- package/test/jp-decoder-test.js +0 -304
- package/test/limited-passthrough-test.js +0 -299
- package/test/memory-cleanup-test.js +0 -144
- package/test/memory-leak-test.js +0 -667
- package/test/parser-limits-test.js +0 -292
- package/test/proxy-connection-test.js +0 -738
- package/test/reliability-improvements-test.js +0 -548
- package/test/search-compiler-test.js +0 -1300
- package/test/search-test.js +0 -329
- package/test/special-use-test.js +0 -418
- package/test/starttls-injection-test.js +0 -181
- package/test/tag-correlation-test.js +0 -333
- package/test/timer-policy-test.js +0 -227
- package/test/token-parser-test.js +0 -456
- package/test/tools-test.js +0 -2013
- package/test/unhandled-rejection-test.js +0 -661
|
@@ -1,909 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Tests for the fetch generator and the download / downloadMany streaming
|
|
4
|
-
// orchestration in ImapFlow. These exercise the real method bodies while
|
|
5
|
-
// stubbing the lower-level run()/fetchOne() so message data is fully controlled.
|
|
6
|
-
|
|
7
|
-
const { ImapFlow } = require('../lib/imap-flow');
|
|
8
|
-
const libbase64 = require('libbase64');
|
|
9
|
-
const libqp = require('libqp');
|
|
10
|
-
const libmime = require('libmime');
|
|
11
|
-
const { finished } = require('stream');
|
|
12
|
-
const { chunkedFetchOne, installRejectionDetector, slowConsumer } = require('./fixtures/test-client');
|
|
13
|
-
|
|
14
|
-
const makeClient = (overrides = {}) => {
|
|
15
|
-
let client = new ImapFlow({
|
|
16
|
-
host: 'imap.example.com',
|
|
17
|
-
port: 993,
|
|
18
|
-
auth: { user: 'test', pass: 'test' },
|
|
19
|
-
logger: false,
|
|
20
|
-
...overrides
|
|
21
|
-
});
|
|
22
|
-
client.socket = { destroyed: false, destroy: () => {} };
|
|
23
|
-
client.usable = true;
|
|
24
|
-
client.mailbox = { path: 'INBOX', exists: 10 };
|
|
25
|
-
client.state = client.states.SELECTED;
|
|
26
|
-
return client;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const collect = stream =>
|
|
30
|
-
new Promise((resolve, reject) => {
|
|
31
|
-
let chunks = [];
|
|
32
|
-
stream.on('data', c => chunks.push(c));
|
|
33
|
-
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
|
34
|
-
stream.on('error', reject);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
// ============================================================================
|
|
38
|
-
// fetch() generator
|
|
39
|
-
// ============================================================================
|
|
40
|
-
|
|
41
|
-
module.exports['Fetch: generator returns nothing without mailbox'] = async test => {
|
|
42
|
-
let client = makeClient();
|
|
43
|
-
client.mailbox = false;
|
|
44
|
-
let results = [];
|
|
45
|
-
for await (let msg of client.fetch('1:*', { uid: true })) {
|
|
46
|
-
results.push(msg);
|
|
47
|
-
}
|
|
48
|
-
test.equal(results.length, 0);
|
|
49
|
-
test.done();
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
module.exports['Fetch: generator returns when range resolves empty'] = async test => {
|
|
53
|
-
let client = makeClient();
|
|
54
|
-
let results = [];
|
|
55
|
-
for await (let msg of client.fetch([], { uid: true })) {
|
|
56
|
-
results.push(msg);
|
|
57
|
-
}
|
|
58
|
-
test.equal(results.length, 0);
|
|
59
|
-
test.done();
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
module.exports['Fetch: generator yields untagged fetch responses'] = async test => {
|
|
63
|
-
let client = makeClient();
|
|
64
|
-
client.run = async (command, range, query, options) => {
|
|
65
|
-
test.equal(command, 'FETCH');
|
|
66
|
-
// Simulate the FETCH handler pushing two rows
|
|
67
|
-
options.onUntaggedFetch({ seq: 1, uid: 11 }, () => {});
|
|
68
|
-
options.onUntaggedFetch({ seq: 2, uid: 12 }, () => {});
|
|
69
|
-
return true;
|
|
70
|
-
};
|
|
71
|
-
let results = [];
|
|
72
|
-
for await (let msg of client.fetch('1:2', { uid: true })) {
|
|
73
|
-
results.push(msg);
|
|
74
|
-
}
|
|
75
|
-
test.equal(results.length, 2);
|
|
76
|
-
test.equal(results[0].uid, 11);
|
|
77
|
-
test.equal(results[1].uid, 12);
|
|
78
|
-
test.done();
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
module.exports['Fetch: generator breaks early and drains backpressure'] = async test => {
|
|
82
|
-
let client = makeClient();
|
|
83
|
-
let nextCalls = 0;
|
|
84
|
-
client.run = async (command, range, query, options) => {
|
|
85
|
-
options.onUntaggedFetch({ seq: 1, uid: 11 }, () => nextCalls++);
|
|
86
|
-
options.onUntaggedFetch({ seq: 2, uid: 12 }, () => nextCalls++);
|
|
87
|
-
options.onUntaggedFetch({ seq: 3, uid: 13 }, () => nextCalls++);
|
|
88
|
-
return true;
|
|
89
|
-
};
|
|
90
|
-
let results = [];
|
|
91
|
-
for await (let msg of client.fetch('1:3', {})) {
|
|
92
|
-
results.push(msg);
|
|
93
|
-
break; // exit early -> finally{} must drain remaining next() callbacks
|
|
94
|
-
}
|
|
95
|
-
test.equal(results.length, 1);
|
|
96
|
-
// The yielded item's next plus the queued items get drained
|
|
97
|
-
test.ok(nextCalls >= 1);
|
|
98
|
-
test.done();
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
module.exports['Fetch: generator throws when run rejects'] = async test => {
|
|
102
|
-
let client = makeClient();
|
|
103
|
-
client.run = async () => {
|
|
104
|
-
throw new Error('FETCH failed');
|
|
105
|
-
};
|
|
106
|
-
let threw = null;
|
|
107
|
-
try {
|
|
108
|
-
// eslint-disable-next-line no-unused-vars
|
|
109
|
-
for await (let msg of client.fetch('1:2', {})) {
|
|
110
|
-
// no-op
|
|
111
|
-
}
|
|
112
|
-
} catch (err) {
|
|
113
|
-
threw = err;
|
|
114
|
-
}
|
|
115
|
-
test.ok(threw);
|
|
116
|
-
test.equal(threw.message, 'FETCH failed');
|
|
117
|
-
test.done();
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
module.exports['Fetch: generator throws if connection closes mid-iteration'] = async test => {
|
|
121
|
-
let client = makeClient();
|
|
122
|
-
client.run = async (command, range, query, options) => {
|
|
123
|
-
// queue two rows so a second loop iteration runs after we close the socket
|
|
124
|
-
options.onUntaggedFetch({ seq: 1, uid: 11 }, () => {});
|
|
125
|
-
options.onUntaggedFetch({ seq: 2, uid: 12 }, () => {});
|
|
126
|
-
return true;
|
|
127
|
-
};
|
|
128
|
-
let threw = null;
|
|
129
|
-
try {
|
|
130
|
-
// eslint-disable-next-line no-unused-vars
|
|
131
|
-
for await (let msg of client.fetch('1:2', {})) {
|
|
132
|
-
// close the connection before the next iteration's guard runs
|
|
133
|
-
client.socket.destroyed = true;
|
|
134
|
-
}
|
|
135
|
-
} catch (err) {
|
|
136
|
-
threw = err;
|
|
137
|
-
}
|
|
138
|
-
test.ok(threw);
|
|
139
|
-
test.equal(threw.code, 'EConnectionClosed');
|
|
140
|
-
test.done();
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
// ============================================================================
|
|
144
|
-
// fetchOne() / fetchAll()
|
|
145
|
-
// ============================================================================
|
|
146
|
-
|
|
147
|
-
module.exports['Fetch: fetchOne returns undefined without mailbox'] = async test => {
|
|
148
|
-
let client = makeClient();
|
|
149
|
-
client.mailbox = false;
|
|
150
|
-
let res = await client.fetchOne('1', {});
|
|
151
|
-
test.equal(res, undefined);
|
|
152
|
-
test.done();
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
module.exports['Fetch: fetchOne star returns false on empty mailbox'] = async test => {
|
|
156
|
-
let client = makeClient();
|
|
157
|
-
client.mailbox.exists = 0;
|
|
158
|
-
let res = await client.fetchOne('*', {});
|
|
159
|
-
test.equal(res, false);
|
|
160
|
-
test.done();
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
module.exports['Fetch: fetchOne star uses last message seq'] = async test => {
|
|
164
|
-
let client = makeClient();
|
|
165
|
-
client.mailbox.exists = 7;
|
|
166
|
-
let captured = null;
|
|
167
|
-
client.run = async (command, seq, query, options) => {
|
|
168
|
-
captured = { seq, options };
|
|
169
|
-
return { list: [{ seq: 7, uid: 70 }] };
|
|
170
|
-
};
|
|
171
|
-
let res = await client.fetchOne('*', {});
|
|
172
|
-
test.equal(captured.seq, '7');
|
|
173
|
-
test.equal(captured.options.uid, false);
|
|
174
|
-
test.equal(res.uid, 70);
|
|
175
|
-
test.done();
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
module.exports['Fetch: fetchOne returns false when list empty'] = async test => {
|
|
179
|
-
let client = makeClient();
|
|
180
|
-
client.run = async () => ({ list: [] });
|
|
181
|
-
let res = await client.fetchOne('1', {});
|
|
182
|
-
test.equal(res, false);
|
|
183
|
-
test.done();
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
module.exports['Fetch: fetchOne returns first message'] = async test => {
|
|
187
|
-
let client = makeClient();
|
|
188
|
-
client.run = async () => ({ list: [{ seq: 1, uid: 11 }] });
|
|
189
|
-
let res = await client.fetchOne('1', { uid: true });
|
|
190
|
-
test.equal(res.uid, 11);
|
|
191
|
-
test.done();
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
module.exports['Fetch: fetchAll collects all generator results'] = async test => {
|
|
195
|
-
let client = makeClient();
|
|
196
|
-
client.run = async (command, range, query, options) => {
|
|
197
|
-
options.onUntaggedFetch({ seq: 1, uid: 11 }, () => {});
|
|
198
|
-
options.onUntaggedFetch({ seq: 2, uid: 12 }, () => {});
|
|
199
|
-
return true;
|
|
200
|
-
};
|
|
201
|
-
let all = await client.fetchAll('1:2', {});
|
|
202
|
-
test.equal(all.length, 2);
|
|
203
|
-
test.done();
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
// ============================================================================
|
|
207
|
-
// download()
|
|
208
|
-
// ============================================================================
|
|
209
|
-
|
|
210
|
-
module.exports['Download: returns empty object without mailbox'] = async test => {
|
|
211
|
-
let client = makeClient();
|
|
212
|
-
client.mailbox = false;
|
|
213
|
-
let res = await client.download('1');
|
|
214
|
-
test.deepEqual(res, {});
|
|
215
|
-
test.done();
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
module.exports['Download: full message in multiple chunks'] = async test => {
|
|
219
|
-
let client = makeClient();
|
|
220
|
-
let body = Buffer.from('A'.repeat(10));
|
|
221
|
-
client.fetchOne = chunkedFetchOne(body);
|
|
222
|
-
let { meta, content } = await client.download('1', false, { chunkSize: 4 });
|
|
223
|
-
test.equal(meta.contentType, 'message/rfc822');
|
|
224
|
-
test.equal(meta.expectedSize, 10);
|
|
225
|
-
let data = await collect(content);
|
|
226
|
-
test.equal(data.toString(), 'A'.repeat(10));
|
|
227
|
-
test.done();
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
module.exports['Download: falls back to default chunkSize/maxBytes when zero'] = async test => {
|
|
231
|
-
let client = makeClient();
|
|
232
|
-
let body = Buffer.from('tiny');
|
|
233
|
-
client.fetchOne = chunkedFetchOne(body);
|
|
234
|
-
// zero values are falsy -> the (|| default) fallbacks kick in
|
|
235
|
-
let { content } = await client.download('1', false, { chunkSize: 0, maxBytes: 0 });
|
|
236
|
-
let data = await collect(content);
|
|
237
|
-
test.equal(data.toString(), 'tiny');
|
|
238
|
-
test.done();
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
module.exports['Download: flowed pipeline forwards a mid-stream fetch error'] = async test => {
|
|
242
|
-
let client = makeClient();
|
|
243
|
-
let mime = Buffer.from('Content-Type: text/plain; format=flowed\r\nContent-Disposition: inline\r\n\r\n');
|
|
244
|
-
let calls = 0;
|
|
245
|
-
client.fetchOne = async () => {
|
|
246
|
-
calls++;
|
|
247
|
-
if (calls === 1) {
|
|
248
|
-
let bodyParts = new Map();
|
|
249
|
-
bodyParts.set('2.mime', mime);
|
|
250
|
-
bodyParts.set('2', Buffer.alloc(64, 0x61));
|
|
251
|
-
return { uid: 1, size: 1000, bodyParts };
|
|
252
|
-
}
|
|
253
|
-
throw new Error('mid-stream flowed fetch fail');
|
|
254
|
-
};
|
|
255
|
-
let { content } = await client.download('1', '2', { chunkSize: 64 });
|
|
256
|
-
let streamErr;
|
|
257
|
-
content.on('data', () => {});
|
|
258
|
-
// stream.finished() fires once with the error (if any) when the stream is
|
|
259
|
-
// truly done — no end-vs-error resolve race.
|
|
260
|
-
await new Promise(resolve =>
|
|
261
|
-
finished(content, err => {
|
|
262
|
-
streamErr = err;
|
|
263
|
-
resolve();
|
|
264
|
-
})
|
|
265
|
-
);
|
|
266
|
-
test.ok(streamErr, 'error forwarded through the flowed decoder pipeline');
|
|
267
|
-
test.done();
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
module.exports['Download: returns {} when fetchOne yields no chunk'] = async test => {
|
|
271
|
-
let client = makeClient();
|
|
272
|
-
client.fetchOne = async () => false;
|
|
273
|
-
let res = await client.download('1', false, { chunkSize: 4 });
|
|
274
|
-
test.deepEqual(res, {});
|
|
275
|
-
test.done();
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
module.exports['Download: base64 body part is decoded'] = async test => {
|
|
279
|
-
let client = makeClient();
|
|
280
|
-
let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
|
|
281
|
-
let encoded = Buffer.from(libbase64.encode(Buffer.from('Hello World')));
|
|
282
|
-
client.fetchOne = async () => {
|
|
283
|
-
let bodyParts = new Map();
|
|
284
|
-
bodyParts.set('2.mime', mime);
|
|
285
|
-
bodyParts.set('2', encoded);
|
|
286
|
-
return { uid: 1, size: encoded.length, bodyParts };
|
|
287
|
-
};
|
|
288
|
-
let { meta, content } = await client.download('1', '2', { chunkSize: 1024 });
|
|
289
|
-
test.equal(meta.contentType, 'text/plain');
|
|
290
|
-
test.equal(meta.encoding, 'base64');
|
|
291
|
-
let data = await collect(content);
|
|
292
|
-
test.equal(data.toString(), 'Hello World');
|
|
293
|
-
test.done();
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
module.exports['Download: binary-fetched part skips the client-side transfer decoding'] = async test => {
|
|
297
|
-
let client = makeClient();
|
|
298
|
-
// BINARY fetch (RFC 3516, FETCH side folded into IMAP4rev2): the server
|
|
299
|
-
// decodes the content-transfer-encoding itself, so the client gets raw bytes
|
|
300
|
-
// even though the MIME headers still declare base64 - decoding again would
|
|
301
|
-
// corrupt the data. formatMessageResponse records the parts that arrived via
|
|
302
|
-
// BINARY[...] in the binaryParts set, which the mock mirrors here.
|
|
303
|
-
let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
|
|
304
|
-
let decoded = Buffer.from('Hello World');
|
|
305
|
-
client.fetchOne = async () => {
|
|
306
|
-
let bodyParts = new Map();
|
|
307
|
-
bodyParts.set('2.mime', mime);
|
|
308
|
-
bodyParts.set('2', decoded);
|
|
309
|
-
return { uid: 1, size: decoded.length, bodyParts, binaryParts: new Set(['2']) };
|
|
310
|
-
};
|
|
311
|
-
let { meta, content } = await client.download('1', '2', { chunkSize: 1024, binary: true });
|
|
312
|
-
test.equal(meta.encoding, 'base64');
|
|
313
|
-
let data = await collect(content);
|
|
314
|
-
test.equal(data.toString(), 'Hello World');
|
|
315
|
-
test.done();
|
|
316
|
-
};
|
|
317
|
-
|
|
318
|
-
module.exports['DownloadMany: binary-fetched parts skip the client-side transfer decoding'] = async test => {
|
|
319
|
-
let client = makeClient();
|
|
320
|
-
let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
|
|
321
|
-
client.fetchOne = async () => {
|
|
322
|
-
let bodyParts = new Map();
|
|
323
|
-
bodyParts.set('2.mime', mime);
|
|
324
|
-
bodyParts.set('2', Buffer.from('part two'));
|
|
325
|
-
return { uid: 1, bodyParts, binaryParts: new Set(['2']) };
|
|
326
|
-
};
|
|
327
|
-
let res = await client.downloadMany('1', ['2'], { binary: true });
|
|
328
|
-
test.equal(res['2'].content.toString(), 'part two');
|
|
329
|
-
test.equal(res['2'].meta.encoding, 'base64');
|
|
330
|
-
test.done();
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
module.exports['Download: BODY-answered part is still decoded even when binary was requested'] = async test => {
|
|
334
|
-
let client = makeClient();
|
|
335
|
-
// RFC 3516 lets a server answer a BINARY request with a NO or a BODY response
|
|
336
|
-
// (e.g. [UNKNOWN-CTE]) - decoding must follow what actually arrived, so a part
|
|
337
|
-
// absent from binaryParts gets the client-side decoder despite options.binary
|
|
338
|
-
let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
|
|
339
|
-
let encoded = Buffer.from(libbase64.encode(Buffer.from('Hello World')));
|
|
340
|
-
client.fetchOne = async () => {
|
|
341
|
-
let bodyParts = new Map();
|
|
342
|
-
bodyParts.set('2.mime', mime);
|
|
343
|
-
bodyParts.set('2', encoded);
|
|
344
|
-
return { uid: 1, size: encoded.length, bodyParts };
|
|
345
|
-
};
|
|
346
|
-
let { content } = await client.download('1', '2', { chunkSize: 1024, binary: true });
|
|
347
|
-
let data = await collect(content);
|
|
348
|
-
test.equal(data.toString(), 'Hello World');
|
|
349
|
-
test.done();
|
|
350
|
-
};
|
|
351
|
-
|
|
352
|
-
module.exports['Download: quoted-printable inline text with charset + filename'] = async test => {
|
|
353
|
-
let client = makeClient();
|
|
354
|
-
let mime = Buffer.from(
|
|
355
|
-
'Content-Type: text/plain; charset=iso-8859-1; name="f.txt"\r\n' +
|
|
356
|
-
'Content-Transfer-Encoding: quoted-printable\r\n' +
|
|
357
|
-
'Content-Disposition: inline; filename="f.txt"\r\n\r\n'
|
|
358
|
-
);
|
|
359
|
-
let encoded = Buffer.from(libqp.encode(Buffer.from('café', 'latin1')));
|
|
360
|
-
client.fetchOne = async () => {
|
|
361
|
-
let bodyParts = new Map();
|
|
362
|
-
bodyParts.set('3.mime', mime);
|
|
363
|
-
bodyParts.set('3', encoded);
|
|
364
|
-
return { uid: 1, size: encoded.length, bodyParts };
|
|
365
|
-
};
|
|
366
|
-
let { meta, content } = await client.download('1', '3', { chunkSize: 1024 });
|
|
367
|
-
test.equal(meta.encoding, 'quoted-printable');
|
|
368
|
-
test.equal(meta.filename, 'f.txt');
|
|
369
|
-
// charset is converted to utf-8 for text parts
|
|
370
|
-
test.equal(meta.charset, 'utf-8');
|
|
371
|
-
let data = await collect(content);
|
|
372
|
-
test.equal(data.toString(), 'café');
|
|
373
|
-
test.done();
|
|
374
|
-
};
|
|
375
|
-
|
|
376
|
-
module.exports['Download: part 1 single-node message becomes TEXT'] = async test => {
|
|
377
|
-
let client = makeClient();
|
|
378
|
-
let calls = [];
|
|
379
|
-
let textBody = Buffer.from('plain text body');
|
|
380
|
-
client.fetchOne = async (range, query) => {
|
|
381
|
-
calls.push(query);
|
|
382
|
-
if (query.bodyStructure) {
|
|
383
|
-
// single node, no childNodes
|
|
384
|
-
return { uid: 1, size: textBody.length, bodyStructure: { type: 'text/plain' } };
|
|
385
|
-
}
|
|
386
|
-
// part has been rewritten to 'text'
|
|
387
|
-
let bodyParts = new Map();
|
|
388
|
-
bodyParts.set('header', Buffer.from('Content-Type: text/plain\r\n\r\n'));
|
|
389
|
-
bodyParts.set('text', textBody);
|
|
390
|
-
return { uid: 1, size: textBody.length, bodyParts };
|
|
391
|
-
};
|
|
392
|
-
let { meta, content } = await client.download('1', '1', { chunkSize: 1024 });
|
|
393
|
-
test.ok(meta);
|
|
394
|
-
let data = await collect(content);
|
|
395
|
-
test.equal(data.toString(), 'plain text body');
|
|
396
|
-
test.done();
|
|
397
|
-
};
|
|
398
|
-
|
|
399
|
-
module.exports['Download: part 1 returns sentinel when bodyStructure fetch fails'] = async test => {
|
|
400
|
-
let client = makeClient();
|
|
401
|
-
client.fetchOne = async () => false;
|
|
402
|
-
let res = await client.download('1', '1', { chunkSize: 1024 });
|
|
403
|
-
test.deepEqual(res, { response: false, chunk: false });
|
|
404
|
-
test.done();
|
|
405
|
-
};
|
|
406
|
-
|
|
407
|
-
module.exports['Download: maxBytes limits output'] = async test => {
|
|
408
|
-
let client = makeClient();
|
|
409
|
-
let body = Buffer.from('B'.repeat(20));
|
|
410
|
-
client.fetchOne = async (range, query) => {
|
|
411
|
-
let start = query.source.start;
|
|
412
|
-
let maxLength = query.source.maxLength;
|
|
413
|
-
return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) };
|
|
414
|
-
};
|
|
415
|
-
let { content } = await client.download('1', false, { chunkSize: 4, maxBytes: 6 });
|
|
416
|
-
let data = await collect(content);
|
|
417
|
-
test.ok(data.length <= 6);
|
|
418
|
-
test.done();
|
|
419
|
-
};
|
|
420
|
-
|
|
421
|
-
module.exports['Download: part 1 with no detectable content-type treated as text'] = async test => {
|
|
422
|
-
let client = makeClient();
|
|
423
|
-
let textBody = Buffer.from('untyped body text');
|
|
424
|
-
client.fetchOne = async (range, query) => {
|
|
425
|
-
if (query.bodyStructure) {
|
|
426
|
-
return { uid: 1, size: textBody.length, bodyStructure: { type: 'text/plain' } }; // no childNodes
|
|
427
|
-
}
|
|
428
|
-
let bodyParts = new Map();
|
|
429
|
-
// empty mime header -> no contentType detected -> isTextNode via (part==='1' && !contentType)
|
|
430
|
-
bodyParts.set('header', Buffer.from('\r\n'));
|
|
431
|
-
bodyParts.set('text', textBody);
|
|
432
|
-
return { uid: 1, size: textBody.length, bodyParts };
|
|
433
|
-
};
|
|
434
|
-
let { content } = await client.download('1', '1', { chunkSize: 1024 });
|
|
435
|
-
let data = await collect(content);
|
|
436
|
-
test.equal(data.toString(), 'untyped body text');
|
|
437
|
-
test.done();
|
|
438
|
-
};
|
|
439
|
-
|
|
440
|
-
module.exports['Download: returns {} when requested part missing from response'] = async test => {
|
|
441
|
-
let client = makeClient();
|
|
442
|
-
client.fetchOne = async () => {
|
|
443
|
-
// response has a mime header but not the actual part content
|
|
444
|
-
let bodyParts = new Map();
|
|
445
|
-
bodyParts.set('5.mime', Buffer.from('Content-Type: text/plain\r\n\r\n'));
|
|
446
|
-
return { uid: 1, size: 0, bodyParts };
|
|
447
|
-
};
|
|
448
|
-
let res = await client.download('1', '5', { chunkSize: 1024 });
|
|
449
|
-
test.deepEqual(res, {});
|
|
450
|
-
test.done();
|
|
451
|
-
};
|
|
452
|
-
|
|
453
|
-
module.exports['Download: format=flowed inline text is decoded'] = async test => {
|
|
454
|
-
let client = makeClient();
|
|
455
|
-
let mime = Buffer.from('Content-Type: text/plain; format=flowed; delsp=yes\r\nContent-Disposition: inline\r\n\r\n');
|
|
456
|
-
let body = Buffer.from('soft \r\nbroken\r\n');
|
|
457
|
-
client.fetchOne = async () => {
|
|
458
|
-
let bodyParts = new Map();
|
|
459
|
-
bodyParts.set('2.mime', mime);
|
|
460
|
-
bodyParts.set('2', body);
|
|
461
|
-
return { uid: 1, size: body.length, bodyParts };
|
|
462
|
-
};
|
|
463
|
-
let { meta, content } = await client.download('1', '2', { chunkSize: 1024 });
|
|
464
|
-
test.ok(meta.flowed, 'flowed flag detected');
|
|
465
|
-
let data = await collect(content);
|
|
466
|
-
test.ok(data.length >= 0);
|
|
467
|
-
test.done();
|
|
468
|
-
};
|
|
469
|
-
|
|
470
|
-
module.exports['Download: unknown charset is left undecoded'] = async test => {
|
|
471
|
-
let client = makeClient();
|
|
472
|
-
let mime = Buffer.from('Content-Type: text/plain; charset=totally-bogus-xyz\r\nContent-Disposition: inline\r\n\r\n');
|
|
473
|
-
let body = Buffer.from('hello bogus charset');
|
|
474
|
-
client.fetchOne = async () => {
|
|
475
|
-
let bodyParts = new Map();
|
|
476
|
-
bodyParts.set('2.mime', mime);
|
|
477
|
-
bodyParts.set('2', body);
|
|
478
|
-
return { uid: 1, size: body.length, bodyParts };
|
|
479
|
-
};
|
|
480
|
-
let { meta, content } = await client.download('1', '2', { chunkSize: 1024 });
|
|
481
|
-
// getDecoder throws for the bogus charset; charset stays as the original
|
|
482
|
-
test.equal(meta.charset, 'totally-bogus-xyz');
|
|
483
|
-
let data = await collect(content);
|
|
484
|
-
test.equal(data.toString(), 'hello bogus charset');
|
|
485
|
-
test.done();
|
|
486
|
-
};
|
|
487
|
-
|
|
488
|
-
module.exports['Download: stops when a later chunk has no data'] = async test => {
|
|
489
|
-
let client = makeClient();
|
|
490
|
-
let calls = 0;
|
|
491
|
-
client.fetchOne = async () => {
|
|
492
|
-
calls++;
|
|
493
|
-
if (calls <= 3) {
|
|
494
|
-
// full-size chunks keep hasMore true
|
|
495
|
-
return { uid: 1, size: 100, source: Buffer.from('ABCD') };
|
|
496
|
-
}
|
|
497
|
-
// subsequent fetch returns no source -> getNextPart returns {} -> loop breaks
|
|
498
|
-
return { uid: 1, size: 100, source: null };
|
|
499
|
-
};
|
|
500
|
-
let { content } = await client.download('1', false, { chunkSize: 4 });
|
|
501
|
-
let data = await collect(content);
|
|
502
|
-
test.equal(data.toString(), 'ABCDABCDABCD');
|
|
503
|
-
test.done();
|
|
504
|
-
};
|
|
505
|
-
|
|
506
|
-
module.exports['Download: initial chunk waits for drain when buffer full'] = async test => {
|
|
507
|
-
let client = makeClient();
|
|
508
|
-
let big = Buffer.alloc(64 * 1024, 0x61); // 64KB > default 16KB highWaterMark
|
|
509
|
-
let calls = 0;
|
|
510
|
-
client.fetchOne = async () => {
|
|
511
|
-
calls++;
|
|
512
|
-
if (calls <= 2) {
|
|
513
|
-
return { uid: 1, size: 200 * 1024, source: big };
|
|
514
|
-
}
|
|
515
|
-
return { uid: 1, size: 200 * 1024, source: big.slice(0, 10) };
|
|
516
|
-
};
|
|
517
|
-
let { content } = await client.download('1', false, { chunkSize: 64 * 1024 });
|
|
518
|
-
|
|
519
|
-
// Do NOT consume yet: with no consumer the first 64KB write returns false,
|
|
520
|
-
// so download registers a 'drain' listener and waits before fetching more.
|
|
521
|
-
await new Promise(resolve => setTimeout(resolve, 25));
|
|
522
|
-
|
|
523
|
-
// Now drain it.
|
|
524
|
-
let received = 0;
|
|
525
|
-
content.on('data', c => {
|
|
526
|
-
received += c.length;
|
|
527
|
-
});
|
|
528
|
-
await new Promise(resolve => content.on('end', resolve));
|
|
529
|
-
test.ok(received > 64 * 1024);
|
|
530
|
-
test.done();
|
|
531
|
-
};
|
|
532
|
-
|
|
533
|
-
module.exports['Download: in-loop backpressure waits for drain'] = async test => {
|
|
534
|
-
let client = makeClient();
|
|
535
|
-
let big = Buffer.alloc(64 * 1024, 0x61);
|
|
536
|
-
let calls = 0;
|
|
537
|
-
client.fetchOne = async () => {
|
|
538
|
-
calls++;
|
|
539
|
-
if (calls <= 4) {
|
|
540
|
-
return { uid: 1, size: 400 * 1024, source: big };
|
|
541
|
-
}
|
|
542
|
-
return { uid: 1, size: 400 * 1024, source: big.slice(0, 10) };
|
|
543
|
-
};
|
|
544
|
-
let { content } = await client.download('1', false, { chunkSize: 64 * 1024 });
|
|
545
|
-
|
|
546
|
-
// A slow consumer attached immediately. The first write succeeds (data moves
|
|
547
|
-
// one buffer down) but subsequent loop writes return false while the consumer
|
|
548
|
-
// is still draining the previous chunk, exercising the in-loop drain wait.
|
|
549
|
-
let received = 0;
|
|
550
|
-
let slow = slowConsumer({ delay: 40, onChunk: chunk => (received += chunk.length) });
|
|
551
|
-
content.pipe(slow);
|
|
552
|
-
await new Promise(resolve => slow.on('finish', resolve));
|
|
553
|
-
|
|
554
|
-
test.ok(received > 64 * 1024);
|
|
555
|
-
test.done();
|
|
556
|
-
};
|
|
557
|
-
|
|
558
|
-
module.exports['Download: error during streaming surfaces on content stream'] = async test => {
|
|
559
|
-
let client = makeClient();
|
|
560
|
-
let calls = 0;
|
|
561
|
-
client.fetchOne = async () => {
|
|
562
|
-
calls++;
|
|
563
|
-
if (calls === 1) {
|
|
564
|
-
// metadata chunk (full size keeps hasMore true)
|
|
565
|
-
return { uid: 1, size: 100, source: Buffer.alloc(4, 0x61) };
|
|
566
|
-
}
|
|
567
|
-
// subsequent loop fetch fails -> fetchAllParts rejects -> error on stream
|
|
568
|
-
throw new Error('mid-stream fetch failure');
|
|
569
|
-
};
|
|
570
|
-
let { content } = await client.download('1', false, { chunkSize: 4 });
|
|
571
|
-
let streamErr;
|
|
572
|
-
content.on('data', () => {});
|
|
573
|
-
await new Promise(resolve =>
|
|
574
|
-
finished(content, err => {
|
|
575
|
-
streamErr = err;
|
|
576
|
-
resolve();
|
|
577
|
-
})
|
|
578
|
-
);
|
|
579
|
-
test.ok(streamErr, 'error surfaced on content stream');
|
|
580
|
-
test.equal(streamErr.message, 'mid-stream fetch failure');
|
|
581
|
-
test.done();
|
|
582
|
-
};
|
|
583
|
-
|
|
584
|
-
module.exports['Download: a chunk failure after a backpressure wait reaches the stream'] = async test => {
|
|
585
|
-
// Regression: the drain wait cleared the head stream's listeners with
|
|
586
|
-
// removeAllListeners('error'), which also removed the error forwarder the decoder pipeline
|
|
587
|
-
// attached to it. From the first backpressure wait onwards a later chunk failure was emitted
|
|
588
|
-
// onto a listener-less stream, so emit() threw inside the .catch() handler and the rejection
|
|
589
|
-
// escaped as an unhandledRejection instead of reaching the consumer.
|
|
590
|
-
let client = makeClient();
|
|
591
|
-
let big = Buffer.alloc(64 * 1024, 0x61);
|
|
592
|
-
let calls = 0;
|
|
593
|
-
client.fetchOne = async () => {
|
|
594
|
-
calls++;
|
|
595
|
-
if (calls <= 3) {
|
|
596
|
-
return { uid: 1, size: 400 * 1024, source: big };
|
|
597
|
-
}
|
|
598
|
-
let err = new Error('Connection not available');
|
|
599
|
-
err.code = 'NoConnection';
|
|
600
|
-
throw err;
|
|
601
|
-
};
|
|
602
|
-
|
|
603
|
-
const detector = installRejectionDetector(test);
|
|
604
|
-
|
|
605
|
-
let received = 0;
|
|
606
|
-
let streamErr;
|
|
607
|
-
try {
|
|
608
|
-
let { content } = await client.download('1', false, { uid: true, chunkSize: 64 * 1024 });
|
|
609
|
-
|
|
610
|
-
// Backpressure comes from the deferred callback, not from wall-clock delay
|
|
611
|
-
content.pipe(slowConsumer({ onChunk: chunk => (received += chunk.length) }));
|
|
612
|
-
await new Promise(resolve =>
|
|
613
|
-
finished(content, err => {
|
|
614
|
-
streamErr = err;
|
|
615
|
-
resolve();
|
|
616
|
-
})
|
|
617
|
-
);
|
|
618
|
-
} finally {
|
|
619
|
-
detector.check();
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// Without a completed drain wait the regression is not exercised at all, so assert the
|
|
623
|
-
// pipeline actually drained a chunk before the failing fetch rather than inferring it
|
|
624
|
-
test.ok(received >= 64 * 1024, 'a chunk drained through the consumer before the failure');
|
|
625
|
-
test.equal(calls, 4, 'the failing chunk was reached');
|
|
626
|
-
test.ok(streamErr, 'error surfaced on content stream');
|
|
627
|
-
test.equal(streamErr && streamErr.code, 'NoConnection');
|
|
628
|
-
test.done();
|
|
629
|
-
};
|
|
630
|
-
|
|
631
|
-
module.exports['Download: write error on initial chunk surfaces on stream'] = async test => {
|
|
632
|
-
let client = makeClient();
|
|
633
|
-
// a non-Buffer source makes stream.write throw inside the setImmediate try/catch
|
|
634
|
-
client.fetchOne = async () => ({ uid: 1, size: 100, source: 999999 });
|
|
635
|
-
let { content } = await client.download('1', false, { chunkSize: 4 });
|
|
636
|
-
let streamErr;
|
|
637
|
-
content.on('data', () => {});
|
|
638
|
-
await new Promise(resolve =>
|
|
639
|
-
finished(content, err => {
|
|
640
|
-
streamErr = err;
|
|
641
|
-
resolve();
|
|
642
|
-
})
|
|
643
|
-
);
|
|
644
|
-
test.ok(streamErr, 'write error surfaced');
|
|
645
|
-
test.done();
|
|
646
|
-
};
|
|
647
|
-
|
|
648
|
-
module.exports['Download: destroying content before streaming aborts cleanly'] = async test => {
|
|
649
|
-
let client = makeClient();
|
|
650
|
-
let big = Buffer.alloc(64 * 1024, 0x61);
|
|
651
|
-
client.fetchOne = async (range, query) => {
|
|
652
|
-
let start = query.source.start;
|
|
653
|
-
return { uid: 1, size: 500 * 1024, source: big.slice(start, start + 64 * 1024) };
|
|
654
|
-
};
|
|
655
|
-
let { content } = await client.download('1', false, { chunkSize: 64 * 1024 });
|
|
656
|
-
// Destroy immediately: the deferred writeChunk sees a destroyed stream and bails.
|
|
657
|
-
content.destroy();
|
|
658
|
-
await new Promise(resolve => setTimeout(resolve, 30));
|
|
659
|
-
test.ok(true, 'no crash when content destroyed before streaming starts');
|
|
660
|
-
test.done();
|
|
661
|
-
};
|
|
662
|
-
|
|
663
|
-
module.exports['Download: aborting mid-backpressure stops the fetch loop'] = async test => {
|
|
664
|
-
let client = makeClient();
|
|
665
|
-
let big = Buffer.alloc(64 * 1024, 0x61);
|
|
666
|
-
let calls = 0;
|
|
667
|
-
client.fetchOne = async () => {
|
|
668
|
-
calls++;
|
|
669
|
-
if (calls <= 6) {
|
|
670
|
-
return { uid: 1, size: 1000 * 1024, source: big };
|
|
671
|
-
}
|
|
672
|
-
return { uid: 1, size: 1000 * 1024, source: big.slice(0, 10) };
|
|
673
|
-
};
|
|
674
|
-
let { content } = await client.download('1', false, { chunkSize: 64 * 1024 });
|
|
675
|
-
|
|
676
|
-
// Slow consumer to force backpressure, then destroy mid-stream so the loop
|
|
677
|
-
// aborts during a drain wait.
|
|
678
|
-
let slow = slowConsumer({ delay: 50 });
|
|
679
|
-
content.pipe(slow);
|
|
680
|
-
setTimeout(() => content.destroy(), 70);
|
|
681
|
-
await new Promise(resolve => {
|
|
682
|
-
slow.on('finish', resolve);
|
|
683
|
-
content.on('close', resolve);
|
|
684
|
-
content.on('error', resolve);
|
|
685
|
-
});
|
|
686
|
-
test.ok(true, 'aborted mid-backpressure without hanging');
|
|
687
|
-
test.done();
|
|
688
|
-
};
|
|
689
|
-
|
|
690
|
-
// ============================================================================
|
|
691
|
-
// downloadMany()
|
|
692
|
-
// ============================================================================
|
|
693
|
-
|
|
694
|
-
module.exports['DownloadMany: returns empty object without mailbox'] = async test => {
|
|
695
|
-
let client = makeClient();
|
|
696
|
-
client.mailbox = false;
|
|
697
|
-
let res = await client.downloadMany('1', ['2']);
|
|
698
|
-
test.deepEqual(res, {});
|
|
699
|
-
test.done();
|
|
700
|
-
};
|
|
701
|
-
|
|
702
|
-
module.exports['DownloadMany: returns {response:false} when no bodyParts'] = async test => {
|
|
703
|
-
let client = makeClient();
|
|
704
|
-
client.fetchOne = async () => ({ uid: 1 });
|
|
705
|
-
let res = await client.downloadMany('1', ['2']);
|
|
706
|
-
test.deepEqual(res, { response: false });
|
|
707
|
-
test.done();
|
|
708
|
-
};
|
|
709
|
-
|
|
710
|
-
module.exports['DownloadMany: parses charset, flowed and name params'] = async test => {
|
|
711
|
-
let client = makeClient();
|
|
712
|
-
let mime = Buffer.from('Content-Type: text/plain; charset=iso-8859-1; format=flowed; delsp=yes; name="x.txt"\r\nContent-Disposition: inline\r\n\r\n');
|
|
713
|
-
client.fetchOne = async () => {
|
|
714
|
-
let bodyParts = new Map();
|
|
715
|
-
bodyParts.set('2.mime', mime);
|
|
716
|
-
bodyParts.set('2', Buffer.from('plain'));
|
|
717
|
-
return { uid: 1, bodyParts };
|
|
718
|
-
};
|
|
719
|
-
let res = await client.downloadMany('1', ['2']);
|
|
720
|
-
test.equal(res['2'].meta.charset, 'iso-8859-1');
|
|
721
|
-
test.equal(res['2'].meta.flowed, true);
|
|
722
|
-
test.equal(res['2'].meta.delSp, true);
|
|
723
|
-
test.equal(res['2'].meta.filename, 'x.txt');
|
|
724
|
-
test.done();
|
|
725
|
-
};
|
|
726
|
-
|
|
727
|
-
module.exports['DownloadMany: tolerates decodeWords failures for disposition/filename'] = async test => {
|
|
728
|
-
let originalDecodeWords = libmime.decodeWords;
|
|
729
|
-
libmime.decodeWords = () => {
|
|
730
|
-
throw new Error('decodeWords boom');
|
|
731
|
-
};
|
|
732
|
-
try {
|
|
733
|
-
let client = makeClient();
|
|
734
|
-
let mime = Buffer.from('Content-Type: text/plain\r\nContent-Disposition: attachment; filename="f.txt"\r\n\r\n');
|
|
735
|
-
client.fetchOne = async () => {
|
|
736
|
-
let bodyParts = new Map();
|
|
737
|
-
bodyParts.set('2.mime', mime);
|
|
738
|
-
bodyParts.set('2', Buffer.from('x'));
|
|
739
|
-
return { uid: 1, bodyParts };
|
|
740
|
-
};
|
|
741
|
-
let res = await client.downloadMany('1', ['2']);
|
|
742
|
-
// disposition/filename are kept as-is when decodeWords throws
|
|
743
|
-
test.equal(res['2'].meta.disposition, 'attachment');
|
|
744
|
-
test.equal(res['2'].meta.filename, 'f.txt');
|
|
745
|
-
} finally {
|
|
746
|
-
libmime.decodeWords = originalDecodeWords;
|
|
747
|
-
}
|
|
748
|
-
test.done();
|
|
749
|
-
};
|
|
750
|
-
|
|
751
|
-
module.exports['Download: tolerates decodeWords failures for disposition/filename'] = async test => {
|
|
752
|
-
let originalDecodeWords = libmime.decodeWords;
|
|
753
|
-
libmime.decodeWords = () => {
|
|
754
|
-
throw new Error('decodeWords boom');
|
|
755
|
-
};
|
|
756
|
-
try {
|
|
757
|
-
let client = makeClient();
|
|
758
|
-
let mime = Buffer.from('Content-Type: text/plain\r\nContent-Disposition: attachment; filename="f.txt"\r\n\r\n');
|
|
759
|
-
client.fetchOne = async () => {
|
|
760
|
-
let bodyParts = new Map();
|
|
761
|
-
bodyParts.set('2.mime', mime);
|
|
762
|
-
bodyParts.set('2', Buffer.from('hello'));
|
|
763
|
-
return { uid: 1, size: 5, bodyParts };
|
|
764
|
-
};
|
|
765
|
-
let { meta, content } = await client.download('1', '2', { chunkSize: 1024 });
|
|
766
|
-
test.equal(meta.disposition, 'attachment');
|
|
767
|
-
test.equal(meta.filename, 'f.txt');
|
|
768
|
-
await collect(content);
|
|
769
|
-
} finally {
|
|
770
|
-
libmime.decodeWords = originalDecodeWords;
|
|
771
|
-
}
|
|
772
|
-
test.done();
|
|
773
|
-
};
|
|
774
|
-
|
|
775
|
-
module.exports['DownloadMany: encoded parts with no content decode to null'] = async test => {
|
|
776
|
-
let client = makeClient();
|
|
777
|
-
let mime2 = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
|
|
778
|
-
let mime3 = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n');
|
|
779
|
-
client.fetchOne = async () => {
|
|
780
|
-
let bodyParts = new Map();
|
|
781
|
-
// only the mime headers, no actual content parts -> content stays undefined
|
|
782
|
-
bodyParts.set('2.mime', mime2);
|
|
783
|
-
bodyParts.set('3.mime', mime3);
|
|
784
|
-
return { uid: 1, bodyParts };
|
|
785
|
-
};
|
|
786
|
-
let res = await client.downloadMany('1', ['2', '3']);
|
|
787
|
-
test.equal(res['2'].content, null); // base64 decode of missing content -> null
|
|
788
|
-
test.equal(res['3'].content, null); // quoted-printable decode of missing content -> null
|
|
789
|
-
test.done();
|
|
790
|
-
};
|
|
791
|
-
|
|
792
|
-
module.exports['DownloadMany: handles content part arriving before its mime header'] = async test => {
|
|
793
|
-
let client = makeClient();
|
|
794
|
-
client.fetchOne = async () => {
|
|
795
|
-
let bodyParts = new Map();
|
|
796
|
-
bodyParts.set('2', Buffer.from('hello')); // content first
|
|
797
|
-
bodyParts.set('2.mime', Buffer.from('Content-Type: text/plain\r\n\r\n'));
|
|
798
|
-
return { uid: 1, bodyParts };
|
|
799
|
-
};
|
|
800
|
-
let res = await client.downloadMany('1', ['2']);
|
|
801
|
-
test.equal(res['2'].content.toString(), 'hello');
|
|
802
|
-
test.equal(res['2'].meta.contentType, 'text/plain');
|
|
803
|
-
test.done();
|
|
804
|
-
};
|
|
805
|
-
|
|
806
|
-
module.exports['Download: charset pipeline forwards a mid-stream fetch error'] = async test => {
|
|
807
|
-
let client = makeClient();
|
|
808
|
-
let mime = Buffer.from('Content-Type: text/plain; charset=iso-8859-1\r\nContent-Disposition: inline\r\n\r\n');
|
|
809
|
-
let calls = 0;
|
|
810
|
-
client.fetchOne = async () => {
|
|
811
|
-
calls++;
|
|
812
|
-
if (calls === 1) {
|
|
813
|
-
let bodyParts = new Map();
|
|
814
|
-
bodyParts.set('2.mime', mime);
|
|
815
|
-
bodyParts.set('2', Buffer.alloc(64, 0x61)); // full chunk -> hasMore
|
|
816
|
-
return { uid: 1, size: 1000, bodyParts };
|
|
817
|
-
}
|
|
818
|
-
throw new Error('mid-stream charset fetch fail');
|
|
819
|
-
};
|
|
820
|
-
let { content } = await client.download('1', '2', { chunkSize: 64 });
|
|
821
|
-
let streamErr;
|
|
822
|
-
content.on('data', () => {});
|
|
823
|
-
await new Promise(resolve =>
|
|
824
|
-
finished(content, err => {
|
|
825
|
-
streamErr = err;
|
|
826
|
-
resolve();
|
|
827
|
-
})
|
|
828
|
-
);
|
|
829
|
-
test.ok(streamErr, 'error forwarded through the charset decoder pipeline');
|
|
830
|
-
test.done();
|
|
831
|
-
};
|
|
832
|
-
|
|
833
|
-
// Covers the `(part === '1' && !meta.contentType)` text-node branch: for a
|
|
834
|
-
// MULTIPART message, part '1' is NOT rewritten to TEXT, so requesting part '1'
|
|
835
|
-
// whose MIME headers carry no Content-Type reaches that sub-condition.
|
|
836
|
-
module.exports['Download: multipart part 1 without content-type streams as a text node'] = async test => {
|
|
837
|
-
let client = makeClient();
|
|
838
|
-
client.fetchOne = async (range, query) => {
|
|
839
|
-
if (query.bodyStructure) {
|
|
840
|
-
// childNodes present -> multipart -> part '1' stays '1'
|
|
841
|
-
return {
|
|
842
|
-
uid: 1,
|
|
843
|
-
size: 11,
|
|
844
|
-
bodyStructure: { type: 'multipart/mixed', childNodes: [{ type: 'text/plain' }, { type: 'application/octet-stream' }] }
|
|
845
|
-
};
|
|
846
|
-
}
|
|
847
|
-
let bodyParts = new Map();
|
|
848
|
-
// MIME headers with no Content-Type -> meta.contentType stays unset
|
|
849
|
-
bodyParts.set('1.mime', Buffer.from('\r\n'));
|
|
850
|
-
bodyParts.set('1', Buffer.from('hello world'));
|
|
851
|
-
return { uid: 1, size: 11, bodyParts };
|
|
852
|
-
};
|
|
853
|
-
let { content } = await client.download('1', '1', { chunkSize: 64 });
|
|
854
|
-
let data = await collect(content);
|
|
855
|
-
test.equal(data.toString(), 'hello world');
|
|
856
|
-
test.done();
|
|
857
|
-
};
|
|
858
|
-
|
|
859
|
-
module.exports['DownloadMany: decodes base64 and quoted-printable parts'] = async test => {
|
|
860
|
-
let client = makeClient();
|
|
861
|
-
let mime2 = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
|
|
862
|
-
let mime3 = Buffer.from(
|
|
863
|
-
'Content-Type: application/octet-stream; name="a.bin"\r\n' +
|
|
864
|
-
'Content-Transfer-Encoding: quoted-printable\r\n' +
|
|
865
|
-
'Content-Disposition: attachment; filename="a.bin"\r\n\r\n'
|
|
866
|
-
);
|
|
867
|
-
client.fetchOne = async (range, query) => {
|
|
868
|
-
test.ok(query.bodyParts.includes('2.mime'));
|
|
869
|
-
let bodyParts = new Map();
|
|
870
|
-
bodyParts.set('2.mime', mime2);
|
|
871
|
-
bodyParts.set('2', Buffer.from(libbase64.encode(Buffer.from('part two'))));
|
|
872
|
-
bodyParts.set('3.mime', mime3);
|
|
873
|
-
bodyParts.set('3', Buffer.from(libqp.encode(Buffer.from('part three'))));
|
|
874
|
-
return { uid: 1, bodyParts };
|
|
875
|
-
};
|
|
876
|
-
let res = await client.downloadMany('1', ['2', '3']);
|
|
877
|
-
test.equal(res['2'].content.toString(), 'part two');
|
|
878
|
-
test.equal(res['2'].meta.contentType, 'text/plain');
|
|
879
|
-
test.equal(res['3'].content.toString(), 'part three');
|
|
880
|
-
test.equal(res['3'].meta.filename, 'a.bin');
|
|
881
|
-
test.equal(res['3'].meta.disposition, 'attachment');
|
|
882
|
-
test.done();
|
|
883
|
-
};
|
|
884
|
-
|
|
885
|
-
module.exports['Download: a buffering transform cannot defeat maxBytes'] = async test => {
|
|
886
|
-
// format=flowed unwrapping and the Japanese charset decoder both hold their whole input
|
|
887
|
-
// before emitting anything, so the limiter at the tail of the pipeline reports
|
|
888
|
-
// `limited === false` however much the server sends. Without a bound of their own the fetch
|
|
889
|
-
// loop would keep pulling the entire part.
|
|
890
|
-
for (let contentType of ['text/plain; format=flowed', 'text/plain; charset=iso-2022-jp']) {
|
|
891
|
-
let client = makeClient();
|
|
892
|
-
let mime = Buffer.from(`Content-Type: ${contentType}\r\nContent-Disposition: inline\r\n\r\n`);
|
|
893
|
-
let calls = 0;
|
|
894
|
-
client.fetchOne = async () => {
|
|
895
|
-
calls++;
|
|
896
|
-
let bodyParts = new Map();
|
|
897
|
-
bodyParts.set('2.mime', mime);
|
|
898
|
-
// full-size chunks keep hasMore true; the loop must stop on its own, not run dry
|
|
899
|
-
bodyParts.set('2', calls <= 40 ? Buffer.alloc(1024, 0x61) : Buffer.alloc(1));
|
|
900
|
-
return { uid: 1, size: 1024 * 1024, bodyParts };
|
|
901
|
-
};
|
|
902
|
-
|
|
903
|
-
let { content } = await client.download('1', '2', { chunkSize: 1024, maxBytes: 512 });
|
|
904
|
-
let data = await collect(content);
|
|
905
|
-
test.ok(data.length <= 512, `${contentType}: delivered ${data.length} bytes, expected no more than 512`);
|
|
906
|
-
test.ok(calls <= 3, `${contentType}: the fetch loop must stop at the bound, made ${calls} calls`);
|
|
907
|
-
}
|
|
908
|
-
test.done();
|
|
909
|
-
};
|