imapflow 1.3.5 → 1.3.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.
Files changed (44) hide show
  1. package/.github/codeql/codeql-config.yml +12 -0
  2. package/.github/workflows/codeql.yml +102 -0
  3. package/.github/workflows/stale.yml +5 -0
  4. package/.github/workflows/test.yml +3 -0
  5. package/.release-please-manifest.json +1 -1
  6. package/CHANGELOG.md +14 -0
  7. package/lib/commands/authenticate.js +5 -5
  8. package/lib/commands/fetch.js +1 -0
  9. package/lib/commands/logout.js +1 -0
  10. package/lib/commands/select.js +2 -1
  11. package/lib/commands/starttls.js +3 -0
  12. package/lib/commands/store.js +1 -0
  13. package/lib/handler/imap-stream.js +23 -6
  14. package/lib/handler/token-parser.js +0 -5
  15. package/lib/imap-flow.d.ts +5 -0
  16. package/lib/imap-flow.js +205 -29
  17. package/lib/proxy-connection.js +26 -1
  18. package/lib/tools.js +12 -0
  19. package/package.json +1 -1
  20. package/test/commands-branches-test.js +1068 -0
  21. package/test/connection-edge-cases-test.js +138 -1
  22. package/test/fixtures/test-tls.js +8 -0
  23. package/test/handler-branches-test.js +334 -0
  24. package/test/imap-compiler-test.js +47 -0
  25. package/test/imap-flow-compress-test.js +154 -0
  26. package/test/imap-flow-coverage-test.js +609 -0
  27. package/test/imap-flow-fetch-download-test.js +801 -0
  28. package/test/imap-flow-internals-test.js +450 -0
  29. package/test/imap-flow-methods-test.js +738 -0
  30. package/test/imap-flow-proxy-paths-test.js +215 -0
  31. package/test/imap-flow-secure-test.js +327 -0
  32. package/test/imap-flow-server-test.js +1046 -0
  33. package/test/imap-formal-syntax-test.js +18 -0
  34. package/test/imap-parser-test.js +52 -0
  35. package/test/imap-stream-edge-cases-test.js +122 -0
  36. package/test/jp-decoder-test.js +48 -0
  37. package/test/limited-passthrough-test.js +17 -0
  38. package/test/proxy-connection-test.js +86 -0
  39. package/test/reliability-improvements-test.js +88 -0
  40. package/test/search-compiler-test.js +31 -0
  41. package/test/search-test.js +61 -0
  42. package/test/starttls-injection-test.js +181 -0
  43. package/test/token-parser-test.js +64 -0
  44. package/test/tools-test.js +360 -0
@@ -0,0 +1,801 @@
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 { Writable, finished } = require('stream');
12
+
13
+ const makeClient = (overrides = {}) => {
14
+ let client = new ImapFlow({
15
+ host: 'imap.example.com',
16
+ port: 993,
17
+ auth: { user: 'test', pass: 'test' },
18
+ logger: false,
19
+ ...overrides
20
+ });
21
+ client.socket = { destroyed: false, destroy: () => {} };
22
+ client.usable = true;
23
+ client.mailbox = { path: 'INBOX', exists: 10 };
24
+ client.state = client.states.SELECTED;
25
+ return client;
26
+ };
27
+
28
+ const collect = stream =>
29
+ new Promise((resolve, reject) => {
30
+ let chunks = [];
31
+ stream.on('data', c => chunks.push(c));
32
+ stream.on('end', () => resolve(Buffer.concat(chunks)));
33
+ stream.on('error', reject);
34
+ });
35
+
36
+ // ============================================================================
37
+ // fetch() generator
38
+ // ============================================================================
39
+
40
+ module.exports['Fetch: generator returns nothing without mailbox'] = async test => {
41
+ let client = makeClient();
42
+ client.mailbox = false;
43
+ let results = [];
44
+ for await (let msg of client.fetch('1:*', { uid: true })) {
45
+ results.push(msg);
46
+ }
47
+ test.equal(results.length, 0);
48
+ test.done();
49
+ };
50
+
51
+ module.exports['Fetch: generator returns when range resolves empty'] = async test => {
52
+ let client = makeClient();
53
+ let results = [];
54
+ for await (let msg of client.fetch([], { uid: true })) {
55
+ results.push(msg);
56
+ }
57
+ test.equal(results.length, 0);
58
+ test.done();
59
+ };
60
+
61
+ module.exports['Fetch: generator yields untagged fetch responses'] = async test => {
62
+ let client = makeClient();
63
+ client.run = async (command, range, query, options) => {
64
+ test.equal(command, 'FETCH');
65
+ // Simulate the FETCH handler pushing two rows
66
+ options.onUntaggedFetch({ seq: 1, uid: 11 }, () => {});
67
+ options.onUntaggedFetch({ seq: 2, uid: 12 }, () => {});
68
+ return true;
69
+ };
70
+ let results = [];
71
+ for await (let msg of client.fetch('1:2', { uid: true })) {
72
+ results.push(msg);
73
+ }
74
+ test.equal(results.length, 2);
75
+ test.equal(results[0].uid, 11);
76
+ test.equal(results[1].uid, 12);
77
+ test.done();
78
+ };
79
+
80
+ module.exports['Fetch: generator breaks early and drains backpressure'] = async test => {
81
+ let client = makeClient();
82
+ let nextCalls = 0;
83
+ client.run = async (command, range, query, options) => {
84
+ options.onUntaggedFetch({ seq: 1, uid: 11 }, () => nextCalls++);
85
+ options.onUntaggedFetch({ seq: 2, uid: 12 }, () => nextCalls++);
86
+ options.onUntaggedFetch({ seq: 3, uid: 13 }, () => nextCalls++);
87
+ return true;
88
+ };
89
+ let results = [];
90
+ for await (let msg of client.fetch('1:3', {})) {
91
+ results.push(msg);
92
+ break; // exit early -> finally{} must drain remaining next() callbacks
93
+ }
94
+ test.equal(results.length, 1);
95
+ // The yielded item's next plus the queued items get drained
96
+ test.ok(nextCalls >= 1);
97
+ test.done();
98
+ };
99
+
100
+ module.exports['Fetch: generator throws when run rejects'] = async test => {
101
+ let client = makeClient();
102
+ client.run = async () => {
103
+ throw new Error('FETCH failed');
104
+ };
105
+ let threw = null;
106
+ try {
107
+ // eslint-disable-next-line no-unused-vars
108
+ for await (let msg of client.fetch('1:2', {})) {
109
+ // no-op
110
+ }
111
+ } catch (err) {
112
+ threw = err;
113
+ }
114
+ test.ok(threw);
115
+ test.equal(threw.message, 'FETCH failed');
116
+ test.done();
117
+ };
118
+
119
+ module.exports['Fetch: generator throws if connection closes mid-iteration'] = async test => {
120
+ let client = makeClient();
121
+ client.run = async (command, range, query, options) => {
122
+ // queue two rows so a second loop iteration runs after we close the socket
123
+ options.onUntaggedFetch({ seq: 1, uid: 11 }, () => {});
124
+ options.onUntaggedFetch({ seq: 2, uid: 12 }, () => {});
125
+ return true;
126
+ };
127
+ let threw = null;
128
+ try {
129
+ // eslint-disable-next-line no-unused-vars
130
+ for await (let msg of client.fetch('1:2', {})) {
131
+ // close the connection before the next iteration's guard runs
132
+ client.socket.destroyed = true;
133
+ }
134
+ } catch (err) {
135
+ threw = err;
136
+ }
137
+ test.ok(threw);
138
+ test.equal(threw.code, 'EConnectionClosed');
139
+ test.done();
140
+ };
141
+
142
+ // ============================================================================
143
+ // fetchOne() / fetchAll()
144
+ // ============================================================================
145
+
146
+ module.exports['Fetch: fetchOne returns undefined without mailbox'] = async test => {
147
+ let client = makeClient();
148
+ client.mailbox = false;
149
+ let res = await client.fetchOne('1', {});
150
+ test.equal(res, undefined);
151
+ test.done();
152
+ };
153
+
154
+ module.exports['Fetch: fetchOne star returns false on empty mailbox'] = async test => {
155
+ let client = makeClient();
156
+ client.mailbox.exists = 0;
157
+ let res = await client.fetchOne('*', {});
158
+ test.equal(res, false);
159
+ test.done();
160
+ };
161
+
162
+ module.exports['Fetch: fetchOne star uses last message seq'] = async test => {
163
+ let client = makeClient();
164
+ client.mailbox.exists = 7;
165
+ let captured = null;
166
+ client.run = async (command, seq, query, options) => {
167
+ captured = { seq, options };
168
+ return { list: [{ seq: 7, uid: 70 }] };
169
+ };
170
+ let res = await client.fetchOne('*', {});
171
+ test.equal(captured.seq, '7');
172
+ test.equal(captured.options.uid, false);
173
+ test.equal(res.uid, 70);
174
+ test.done();
175
+ };
176
+
177
+ module.exports['Fetch: fetchOne returns false when list empty'] = async test => {
178
+ let client = makeClient();
179
+ client.run = async () => ({ list: [] });
180
+ let res = await client.fetchOne('1', {});
181
+ test.equal(res, false);
182
+ test.done();
183
+ };
184
+
185
+ module.exports['Fetch: fetchOne returns first message'] = async test => {
186
+ let client = makeClient();
187
+ client.run = async () => ({ list: [{ seq: 1, uid: 11 }] });
188
+ let res = await client.fetchOne('1', { uid: true });
189
+ test.equal(res.uid, 11);
190
+ test.done();
191
+ };
192
+
193
+ module.exports['Fetch: fetchAll collects all generator results'] = async test => {
194
+ let client = makeClient();
195
+ client.run = async (command, range, query, options) => {
196
+ options.onUntaggedFetch({ seq: 1, uid: 11 }, () => {});
197
+ options.onUntaggedFetch({ seq: 2, uid: 12 }, () => {});
198
+ return true;
199
+ };
200
+ let all = await client.fetchAll('1:2', {});
201
+ test.equal(all.length, 2);
202
+ test.done();
203
+ };
204
+
205
+ // ============================================================================
206
+ // download()
207
+ // ============================================================================
208
+
209
+ module.exports['Download: returns empty object without mailbox'] = async test => {
210
+ let client = makeClient();
211
+ client.mailbox = false;
212
+ let res = await client.download('1');
213
+ test.deepEqual(res, {});
214
+ test.done();
215
+ };
216
+
217
+ module.exports['Download: full message in multiple chunks'] = async test => {
218
+ let client = makeClient();
219
+ let body = Buffer.from('A'.repeat(10));
220
+ client.fetchOne = async (range, query) => {
221
+ let start = query.source.start;
222
+ let maxLength = query.source.maxLength;
223
+ return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) };
224
+ };
225
+ let { meta, content } = await client.download('1', false, { chunkSize: 4 });
226
+ test.equal(meta.contentType, 'message/rfc822');
227
+ test.equal(meta.expectedSize, 10);
228
+ let data = await collect(content);
229
+ test.equal(data.toString(), 'A'.repeat(10));
230
+ test.done();
231
+ };
232
+
233
+ module.exports['Download: falls back to default chunkSize/maxBytes when zero'] = async test => {
234
+ let client = makeClient();
235
+ let body = Buffer.from('tiny');
236
+ client.fetchOne = async (range, query) => {
237
+ let start = query.source.start;
238
+ let maxLength = query.source.maxLength;
239
+ return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) };
240
+ };
241
+ // zero values are falsy -> the (|| default) fallbacks kick in
242
+ let { content } = await client.download('1', false, { chunkSize: 0, maxBytes: 0 });
243
+ let data = await collect(content);
244
+ test.equal(data.toString(), 'tiny');
245
+ test.done();
246
+ };
247
+
248
+ module.exports['Download: flowed pipeline forwards a mid-stream fetch error'] = async test => {
249
+ let client = makeClient();
250
+ let mime = Buffer.from('Content-Type: text/plain; format=flowed\r\nContent-Disposition: inline\r\n\r\n');
251
+ let calls = 0;
252
+ client.fetchOne = async () => {
253
+ calls++;
254
+ if (calls === 1) {
255
+ let bodyParts = new Map();
256
+ bodyParts.set('2.mime', mime);
257
+ bodyParts.set('2', Buffer.alloc(64, 0x61));
258
+ return { uid: 1, size: 1000, bodyParts };
259
+ }
260
+ throw new Error('mid-stream flowed fetch fail');
261
+ };
262
+ let { content } = await client.download('1', '2', { chunkSize: 64 });
263
+ let streamErr;
264
+ content.on('data', () => {});
265
+ // stream.finished() fires once with the error (if any) when the stream is
266
+ // truly done — no end-vs-error resolve race.
267
+ await new Promise(resolve =>
268
+ finished(content, err => {
269
+ streamErr = err;
270
+ resolve();
271
+ })
272
+ );
273
+ test.ok(streamErr, 'error forwarded through the flowed decoder pipeline');
274
+ test.done();
275
+ };
276
+
277
+ module.exports['Download: returns {} when fetchOne yields no chunk'] = async test => {
278
+ let client = makeClient();
279
+ client.fetchOne = async () => false;
280
+ let res = await client.download('1', false, { chunkSize: 4 });
281
+ test.deepEqual(res, {});
282
+ test.done();
283
+ };
284
+
285
+ module.exports['Download: base64 body part is decoded'] = async test => {
286
+ let client = makeClient();
287
+ let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
288
+ let encoded = Buffer.from(libbase64.encode(Buffer.from('Hello World')));
289
+ client.fetchOne = async () => {
290
+ let bodyParts = new Map();
291
+ bodyParts.set('2.mime', mime);
292
+ bodyParts.set('2', encoded);
293
+ return { uid: 1, size: encoded.length, bodyParts };
294
+ };
295
+ let { meta, content } = await client.download('1', '2', { chunkSize: 1024 });
296
+ test.equal(meta.contentType, 'text/plain');
297
+ test.equal(meta.encoding, 'base64');
298
+ let data = await collect(content);
299
+ test.equal(data.toString(), 'Hello World');
300
+ test.done();
301
+ };
302
+
303
+ module.exports['Download: quoted-printable inline text with charset + filename'] = async test => {
304
+ let client = makeClient();
305
+ let mime = Buffer.from(
306
+ 'Content-Type: text/plain; charset=iso-8859-1; name="f.txt"\r\n' +
307
+ 'Content-Transfer-Encoding: quoted-printable\r\n' +
308
+ 'Content-Disposition: inline; filename="f.txt"\r\n\r\n'
309
+ );
310
+ let encoded = Buffer.from(libqp.encode(Buffer.from('café', 'latin1')));
311
+ client.fetchOne = async () => {
312
+ let bodyParts = new Map();
313
+ bodyParts.set('3.mime', mime);
314
+ bodyParts.set('3', encoded);
315
+ return { uid: 1, size: encoded.length, bodyParts };
316
+ };
317
+ let { meta, content } = await client.download('1', '3', { chunkSize: 1024 });
318
+ test.equal(meta.encoding, 'quoted-printable');
319
+ test.equal(meta.filename, 'f.txt');
320
+ // charset is converted to utf-8 for text parts
321
+ test.equal(meta.charset, 'utf-8');
322
+ let data = await collect(content);
323
+ test.equal(data.toString(), 'café');
324
+ test.done();
325
+ };
326
+
327
+ module.exports['Download: part 1 single-node message becomes TEXT'] = async test => {
328
+ let client = makeClient();
329
+ let calls = [];
330
+ let textBody = Buffer.from('plain text body');
331
+ client.fetchOne = async (range, query) => {
332
+ calls.push(query);
333
+ if (query.bodyStructure) {
334
+ // single node, no childNodes
335
+ return { uid: 1, size: textBody.length, bodyStructure: { type: 'text/plain' } };
336
+ }
337
+ // part has been rewritten to 'text'
338
+ let bodyParts = new Map();
339
+ bodyParts.set('header', Buffer.from('Content-Type: text/plain\r\n\r\n'));
340
+ bodyParts.set('text', textBody);
341
+ return { uid: 1, size: textBody.length, bodyParts };
342
+ };
343
+ let { meta, content } = await client.download('1', '1', { chunkSize: 1024 });
344
+ test.ok(meta);
345
+ let data = await collect(content);
346
+ test.equal(data.toString(), 'plain text body');
347
+ test.done();
348
+ };
349
+
350
+ module.exports['Download: part 1 returns sentinel when bodyStructure fetch fails'] = async test => {
351
+ let client = makeClient();
352
+ client.fetchOne = async () => false;
353
+ let res = await client.download('1', '1', { chunkSize: 1024 });
354
+ test.deepEqual(res, { response: false, chunk: false });
355
+ test.done();
356
+ };
357
+
358
+ module.exports['Download: maxBytes limits output'] = async test => {
359
+ let client = makeClient();
360
+ let body = Buffer.from('B'.repeat(20));
361
+ client.fetchOne = async (range, query) => {
362
+ let start = query.source.start;
363
+ let maxLength = query.source.maxLength;
364
+ return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) };
365
+ };
366
+ let { content } = await client.download('1', false, { chunkSize: 4, maxBytes: 6 });
367
+ let data = await collect(content);
368
+ test.ok(data.length <= 6);
369
+ test.done();
370
+ };
371
+
372
+ module.exports['Download: part 1 with no detectable content-type treated as text'] = async test => {
373
+ let client = makeClient();
374
+ let textBody = Buffer.from('untyped body text');
375
+ client.fetchOne = async (range, query) => {
376
+ if (query.bodyStructure) {
377
+ return { uid: 1, size: textBody.length, bodyStructure: { type: 'text/plain' } }; // no childNodes
378
+ }
379
+ let bodyParts = new Map();
380
+ // empty mime header -> no contentType detected -> isTextNode via (part==='1' && !contentType)
381
+ bodyParts.set('header', Buffer.from('\r\n'));
382
+ bodyParts.set('text', textBody);
383
+ return { uid: 1, size: textBody.length, bodyParts };
384
+ };
385
+ let { content } = await client.download('1', '1', { chunkSize: 1024 });
386
+ let data = await collect(content);
387
+ test.equal(data.toString(), 'untyped body text');
388
+ test.done();
389
+ };
390
+
391
+ module.exports['Download: returns {} when requested part missing from response'] = async test => {
392
+ let client = makeClient();
393
+ client.fetchOne = async () => {
394
+ // response has a mime header but not the actual part content
395
+ let bodyParts = new Map();
396
+ bodyParts.set('5.mime', Buffer.from('Content-Type: text/plain\r\n\r\n'));
397
+ return { uid: 1, size: 0, bodyParts };
398
+ };
399
+ let res = await client.download('1', '5', { chunkSize: 1024 });
400
+ test.deepEqual(res, {});
401
+ test.done();
402
+ };
403
+
404
+ module.exports['Download: format=flowed inline text is decoded'] = async test => {
405
+ let client = makeClient();
406
+ let mime = Buffer.from('Content-Type: text/plain; format=flowed; delsp=yes\r\nContent-Disposition: inline\r\n\r\n');
407
+ let body = Buffer.from('soft \r\nbroken\r\n');
408
+ client.fetchOne = async () => {
409
+ let bodyParts = new Map();
410
+ bodyParts.set('2.mime', mime);
411
+ bodyParts.set('2', body);
412
+ return { uid: 1, size: body.length, bodyParts };
413
+ };
414
+ let { meta, content } = await client.download('1', '2', { chunkSize: 1024 });
415
+ test.ok(meta.flowed, 'flowed flag detected');
416
+ let data = await collect(content);
417
+ test.ok(data.length >= 0);
418
+ test.done();
419
+ };
420
+
421
+ module.exports['Download: unknown charset is left undecoded'] = async test => {
422
+ let client = makeClient();
423
+ let mime = Buffer.from('Content-Type: text/plain; charset=totally-bogus-xyz\r\nContent-Disposition: inline\r\n\r\n');
424
+ let body = Buffer.from('hello bogus charset');
425
+ client.fetchOne = async () => {
426
+ let bodyParts = new Map();
427
+ bodyParts.set('2.mime', mime);
428
+ bodyParts.set('2', body);
429
+ return { uid: 1, size: body.length, bodyParts };
430
+ };
431
+ let { meta, content } = await client.download('1', '2', { chunkSize: 1024 });
432
+ // getDecoder throws for the bogus charset; charset stays as the original
433
+ test.equal(meta.charset, 'totally-bogus-xyz');
434
+ let data = await collect(content);
435
+ test.equal(data.toString(), 'hello bogus charset');
436
+ test.done();
437
+ };
438
+
439
+ module.exports['Download: stops when a later chunk has no data'] = async test => {
440
+ let client = makeClient();
441
+ let calls = 0;
442
+ client.fetchOne = async () => {
443
+ calls++;
444
+ if (calls <= 3) {
445
+ // full-size chunks keep hasMore true
446
+ return { uid: 1, size: 100, source: Buffer.from('ABCD') };
447
+ }
448
+ // subsequent fetch returns no source -> getNextPart returns {} -> loop breaks
449
+ return { uid: 1, size: 100, source: null };
450
+ };
451
+ let { content } = await client.download('1', false, { chunkSize: 4 });
452
+ let data = await collect(content);
453
+ test.equal(data.toString(), 'ABCDABCDABCD');
454
+ test.done();
455
+ };
456
+
457
+ module.exports['Download: initial chunk waits for drain when buffer full'] = async test => {
458
+ let client = makeClient();
459
+ let big = Buffer.alloc(64 * 1024, 0x61); // 64KB > default 16KB highWaterMark
460
+ let calls = 0;
461
+ client.fetchOne = async () => {
462
+ calls++;
463
+ if (calls <= 2) {
464
+ return { uid: 1, size: 200 * 1024, source: big };
465
+ }
466
+ return { uid: 1, size: 200 * 1024, source: big.slice(0, 10) };
467
+ };
468
+ let { content } = await client.download('1', false, { chunkSize: 64 * 1024 });
469
+
470
+ // Do NOT consume yet: with no consumer the first 64KB write returns false,
471
+ // so download registers a 'drain' listener and waits before fetching more.
472
+ await new Promise(resolve => setTimeout(resolve, 25));
473
+
474
+ // Now drain it.
475
+ let received = 0;
476
+ content.on('data', c => {
477
+ received += c.length;
478
+ });
479
+ await new Promise(resolve => content.on('end', resolve));
480
+ test.ok(received > 64 * 1024);
481
+ test.done();
482
+ };
483
+
484
+ module.exports['Download: in-loop backpressure waits for drain'] = async test => {
485
+ let client = makeClient();
486
+ let big = Buffer.alloc(64 * 1024, 0x61);
487
+ let calls = 0;
488
+ client.fetchOne = async () => {
489
+ calls++;
490
+ if (calls <= 4) {
491
+ return { uid: 1, size: 400 * 1024, source: big };
492
+ }
493
+ return { uid: 1, size: 400 * 1024, source: big.slice(0, 10) };
494
+ };
495
+ let { content } = await client.download('1', false, { chunkSize: 64 * 1024 });
496
+
497
+ // A slow consumer attached immediately. The first write succeeds (data moves
498
+ // one buffer down) but subsequent loop writes return false while the consumer
499
+ // is still draining the previous chunk, exercising the in-loop drain wait.
500
+ let received = 0;
501
+ let slow = new Writable({
502
+ highWaterMark: 1,
503
+ write(chunk, enc, cb) {
504
+ received += chunk.length;
505
+ setTimeout(cb, 40);
506
+ }
507
+ });
508
+ content.pipe(slow);
509
+ await new Promise(resolve => slow.on('finish', resolve));
510
+
511
+ test.ok(received > 64 * 1024);
512
+ test.done();
513
+ };
514
+
515
+ module.exports['Download: error during streaming surfaces on content stream'] = async test => {
516
+ let client = makeClient();
517
+ let calls = 0;
518
+ client.fetchOne = async () => {
519
+ calls++;
520
+ if (calls === 1) {
521
+ // metadata chunk (full size keeps hasMore true)
522
+ return { uid: 1, size: 100, source: Buffer.alloc(4, 0x61) };
523
+ }
524
+ // subsequent loop fetch fails -> fetchAllParts rejects -> error on stream
525
+ throw new Error('mid-stream fetch failure');
526
+ };
527
+ let { content } = await client.download('1', false, { chunkSize: 4 });
528
+ let streamErr;
529
+ content.on('data', () => {});
530
+ await new Promise(resolve =>
531
+ finished(content, err => {
532
+ streamErr = err;
533
+ resolve();
534
+ })
535
+ );
536
+ test.ok(streamErr, 'error surfaced on content stream');
537
+ test.equal(streamErr.message, 'mid-stream fetch failure');
538
+ test.done();
539
+ };
540
+
541
+ module.exports['Download: write error on initial chunk surfaces on stream'] = async test => {
542
+ let client = makeClient();
543
+ // a non-Buffer source makes stream.write throw inside the setImmediate try/catch
544
+ client.fetchOne = async () => ({ uid: 1, size: 100, source: 999999 });
545
+ let { content } = await client.download('1', false, { chunkSize: 4 });
546
+ let streamErr;
547
+ content.on('data', () => {});
548
+ await new Promise(resolve =>
549
+ finished(content, err => {
550
+ streamErr = err;
551
+ resolve();
552
+ })
553
+ );
554
+ test.ok(streamErr, 'write error surfaced');
555
+ test.done();
556
+ };
557
+
558
+ module.exports['Download: destroying content before streaming aborts cleanly'] = async test => {
559
+ let client = makeClient();
560
+ let big = Buffer.alloc(64 * 1024, 0x61);
561
+ client.fetchOne = async (range, query) => {
562
+ let start = query.source.start;
563
+ return { uid: 1, size: 500 * 1024, source: big.slice(start, start + 64 * 1024) };
564
+ };
565
+ let { content } = await client.download('1', false, { chunkSize: 64 * 1024 });
566
+ // Destroy immediately: the deferred writeChunk sees a destroyed stream and bails.
567
+ content.destroy();
568
+ await new Promise(resolve => setTimeout(resolve, 30));
569
+ test.ok(true, 'no crash when content destroyed before streaming starts');
570
+ test.done();
571
+ };
572
+
573
+ module.exports['Download: aborting mid-backpressure stops the fetch loop'] = async test => {
574
+ let client = makeClient();
575
+ let big = Buffer.alloc(64 * 1024, 0x61);
576
+ let calls = 0;
577
+ client.fetchOne = async () => {
578
+ calls++;
579
+ if (calls <= 6) {
580
+ return { uid: 1, size: 1000 * 1024, source: big };
581
+ }
582
+ return { uid: 1, size: 1000 * 1024, source: big.slice(0, 10) };
583
+ };
584
+ let { content } = await client.download('1', false, { chunkSize: 64 * 1024 });
585
+
586
+ // Slow consumer to force backpressure, then destroy mid-stream so the loop
587
+ // aborts during a drain wait.
588
+ let slow = new Writable({
589
+ highWaterMark: 1,
590
+ write(chunk, enc, cb) {
591
+ setTimeout(cb, 50);
592
+ }
593
+ });
594
+ content.pipe(slow);
595
+ setTimeout(() => content.destroy(), 70);
596
+ await new Promise(resolve => {
597
+ slow.on('finish', resolve);
598
+ content.on('close', resolve);
599
+ content.on('error', resolve);
600
+ });
601
+ test.ok(true, 'aborted mid-backpressure without hanging');
602
+ test.done();
603
+ };
604
+
605
+ // ============================================================================
606
+ // downloadMany()
607
+ // ============================================================================
608
+
609
+ module.exports['DownloadMany: returns empty object without mailbox'] = async test => {
610
+ let client = makeClient();
611
+ client.mailbox = false;
612
+ let res = await client.downloadMany('1', ['2']);
613
+ test.deepEqual(res, {});
614
+ test.done();
615
+ };
616
+
617
+ module.exports['DownloadMany: returns {response:false} when no bodyParts'] = async test => {
618
+ let client = makeClient();
619
+ client.fetchOne = async () => ({ uid: 1 });
620
+ let res = await client.downloadMany('1', ['2']);
621
+ test.deepEqual(res, { response: false });
622
+ test.done();
623
+ };
624
+
625
+ module.exports['DownloadMany: parses charset, flowed and name params'] = async test => {
626
+ let client = makeClient();
627
+ let mime = Buffer.from(
628
+ 'Content-Type: text/plain; charset=iso-8859-1; format=flowed; delsp=yes; name="x.txt"\r\n' +
629
+ 'Content-Disposition: inline\r\n\r\n'
630
+ );
631
+ client.fetchOne = async () => {
632
+ let bodyParts = new Map();
633
+ bodyParts.set('2.mime', mime);
634
+ bodyParts.set('2', Buffer.from('plain'));
635
+ return { uid: 1, bodyParts };
636
+ };
637
+ let res = await client.downloadMany('1', ['2']);
638
+ test.equal(res['2'].meta.charset, 'iso-8859-1');
639
+ test.equal(res['2'].meta.flowed, true);
640
+ test.equal(res['2'].meta.delSp, true);
641
+ test.equal(res['2'].meta.filename, 'x.txt');
642
+ test.done();
643
+ };
644
+
645
+ module.exports['DownloadMany: tolerates decodeWords failures for disposition/filename'] = async test => {
646
+ let originalDecodeWords = libmime.decodeWords;
647
+ libmime.decodeWords = () => {
648
+ throw new Error('decodeWords boom');
649
+ };
650
+ try {
651
+ let client = makeClient();
652
+ let mime = Buffer.from('Content-Type: text/plain\r\nContent-Disposition: attachment; filename="f.txt"\r\n\r\n');
653
+ client.fetchOne = async () => {
654
+ let bodyParts = new Map();
655
+ bodyParts.set('2.mime', mime);
656
+ bodyParts.set('2', Buffer.from('x'));
657
+ return { uid: 1, bodyParts };
658
+ };
659
+ let res = await client.downloadMany('1', ['2']);
660
+ // disposition/filename are kept as-is when decodeWords throws
661
+ test.equal(res['2'].meta.disposition, 'attachment');
662
+ test.equal(res['2'].meta.filename, 'f.txt');
663
+ } finally {
664
+ libmime.decodeWords = originalDecodeWords;
665
+ }
666
+ test.done();
667
+ };
668
+
669
+ module.exports['Download: tolerates decodeWords failures for disposition/filename'] = async test => {
670
+ let originalDecodeWords = libmime.decodeWords;
671
+ libmime.decodeWords = () => {
672
+ throw new Error('decodeWords boom');
673
+ };
674
+ try {
675
+ let client = makeClient();
676
+ let mime = Buffer.from('Content-Type: text/plain\r\nContent-Disposition: attachment; filename="f.txt"\r\n\r\n');
677
+ client.fetchOne = async () => {
678
+ let bodyParts = new Map();
679
+ bodyParts.set('2.mime', mime);
680
+ bodyParts.set('2', Buffer.from('hello'));
681
+ return { uid: 1, size: 5, bodyParts };
682
+ };
683
+ let { meta, content } = await client.download('1', '2', { chunkSize: 1024 });
684
+ test.equal(meta.disposition, 'attachment');
685
+ test.equal(meta.filename, 'f.txt');
686
+ await collect(content);
687
+ } finally {
688
+ libmime.decodeWords = originalDecodeWords;
689
+ }
690
+ test.done();
691
+ };
692
+
693
+ module.exports['DownloadMany: encoded parts with no content decode to null'] = async test => {
694
+ let client = makeClient();
695
+ let mime2 = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
696
+ let mime3 = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n');
697
+ client.fetchOne = async () => {
698
+ let bodyParts = new Map();
699
+ // only the mime headers, no actual content parts -> content stays undefined
700
+ bodyParts.set('2.mime', mime2);
701
+ bodyParts.set('3.mime', mime3);
702
+ return { uid: 1, bodyParts };
703
+ };
704
+ let res = await client.downloadMany('1', ['2', '3']);
705
+ test.equal(res['2'].content, null); // base64 decode of missing content -> null
706
+ test.equal(res['3'].content, null); // quoted-printable decode of missing content -> null
707
+ test.done();
708
+ };
709
+
710
+ module.exports['DownloadMany: handles content part arriving before its mime header'] = async test => {
711
+ let client = makeClient();
712
+ client.fetchOne = async () => {
713
+ let bodyParts = new Map();
714
+ bodyParts.set('2', Buffer.from('hello')); // content first
715
+ bodyParts.set('2.mime', Buffer.from('Content-Type: text/plain\r\n\r\n'));
716
+ return { uid: 1, bodyParts };
717
+ };
718
+ let res = await client.downloadMany('1', ['2']);
719
+ test.equal(res['2'].content.toString(), 'hello');
720
+ test.equal(res['2'].meta.contentType, 'text/plain');
721
+ test.done();
722
+ };
723
+
724
+ module.exports['Download: charset pipeline forwards a mid-stream fetch error'] = async test => {
725
+ let client = makeClient();
726
+ let mime = Buffer.from('Content-Type: text/plain; charset=iso-8859-1\r\nContent-Disposition: inline\r\n\r\n');
727
+ let calls = 0;
728
+ client.fetchOne = async () => {
729
+ calls++;
730
+ if (calls === 1) {
731
+ let bodyParts = new Map();
732
+ bodyParts.set('2.mime', mime);
733
+ bodyParts.set('2', Buffer.alloc(64, 0x61)); // full chunk -> hasMore
734
+ return { uid: 1, size: 1000, bodyParts };
735
+ }
736
+ throw new Error('mid-stream charset fetch fail');
737
+ };
738
+ let { content } = await client.download('1', '2', { chunkSize: 64 });
739
+ let streamErr;
740
+ content.on('data', () => {});
741
+ await new Promise(resolve =>
742
+ finished(content, err => {
743
+ streamErr = err;
744
+ resolve();
745
+ })
746
+ );
747
+ test.ok(streamErr, 'error forwarded through the charset decoder pipeline');
748
+ test.done();
749
+ };
750
+
751
+ // Covers the `(part === '1' && !meta.contentType)` text-node branch: for a
752
+ // MULTIPART message, part '1' is NOT rewritten to TEXT, so requesting part '1'
753
+ // whose MIME headers carry no Content-Type reaches that sub-condition.
754
+ module.exports['Download: multipart part 1 without content-type streams as a text node'] = async test => {
755
+ let client = makeClient();
756
+ client.fetchOne = async (range, query) => {
757
+ if (query.bodyStructure) {
758
+ // childNodes present -> multipart -> part '1' stays '1'
759
+ return {
760
+ uid: 1,
761
+ size: 11,
762
+ bodyStructure: { type: 'multipart/mixed', childNodes: [{ type: 'text/plain' }, { type: 'application/octet-stream' }] }
763
+ };
764
+ }
765
+ let bodyParts = new Map();
766
+ // MIME headers with no Content-Type -> meta.contentType stays unset
767
+ bodyParts.set('1.mime', Buffer.from('\r\n'));
768
+ bodyParts.set('1', Buffer.from('hello world'));
769
+ return { uid: 1, size: 11, bodyParts };
770
+ };
771
+ let { content } = await client.download('1', '1', { chunkSize: 64 });
772
+ let data = await collect(content);
773
+ test.equal(data.toString(), 'hello world');
774
+ test.done();
775
+ };
776
+
777
+ module.exports['DownloadMany: decodes base64 and quoted-printable parts'] = async test => {
778
+ let client = makeClient();
779
+ let mime2 = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
780
+ let mime3 = Buffer.from(
781
+ 'Content-Type: application/octet-stream; name="a.bin"\r\n' +
782
+ 'Content-Transfer-Encoding: quoted-printable\r\n' +
783
+ 'Content-Disposition: attachment; filename="a.bin"\r\n\r\n'
784
+ );
785
+ client.fetchOne = async (range, query) => {
786
+ test.ok(query.bodyParts.includes('2.mime'));
787
+ let bodyParts = new Map();
788
+ bodyParts.set('2.mime', mime2);
789
+ bodyParts.set('2', Buffer.from(libbase64.encode(Buffer.from('part two'))));
790
+ bodyParts.set('3.mime', mime3);
791
+ bodyParts.set('3', Buffer.from(libqp.encode(Buffer.from('part three'))));
792
+ return { uid: 1, bodyParts };
793
+ };
794
+ let res = await client.downloadMany('1', ['2', '3']);
795
+ test.equal(res['2'].content.toString(), 'part two');
796
+ test.equal(res['2'].meta.contentType, 'text/plain');
797
+ test.equal(res['3'].content.toString(), 'part three');
798
+ test.equal(res['3'].meta.filename, 'a.bin');
799
+ test.equal(res['3'].meta.disposition, 'attachment');
800
+ test.done();
801
+ };