UNPKG

imapflow

Version:

IMAP Client for Node

802 lines (745 loc) 30.9 kB
'use strict'; // Tests for the fetch generator and the download / downloadMany streaming // orchestration in ImapFlow. These exercise the real method bodies while // stubbing the lower-level run()/fetchOne() so message data is fully controlled. const { ImapFlow } = require('../lib/imap-flow'); const libbase64 = require('libbase64'); const libqp = require('libqp'); const libmime = require('libmime'); const { Writable, finished } = require('stream'); const makeClient = (overrides = {}) => { let client = new ImapFlow({ host: 'imap.example.com', port: 993, auth: { user: 'test', pass: 'test' }, logger: false, ...overrides }); client.socket = { destroyed: false, destroy: () => {} }; client.usable = true; client.mailbox = { path: 'INBOX', exists: 10 }; client.state = client.states.SELECTED; return client; }; const collect = stream => new Promise((resolve, reject) => { let chunks = []; stream.on('data', c => chunks.push(c)); stream.on('end', () => resolve(Buffer.concat(chunks))); stream.on('error', reject); }); // ============================================================================ // fetch() generator // ============================================================================ module.exports['Fetch: generator returns nothing without mailbox'] = async test => { let client = makeClient(); client.mailbox = false; let results = []; for await (let msg of client.fetch('1:*', { uid: true })) { results.push(msg); } test.equal(results.length, 0); test.done(); }; module.exports['Fetch: generator returns when range resolves empty'] = async test => { let client = makeClient(); let results = []; for await (let msg of client.fetch([], { uid: true })) { results.push(msg); } test.equal(results.length, 0); test.done(); }; module.exports['Fetch: generator yields untagged fetch responses'] = async test => { let client = makeClient(); client.run = async (command, range, query, options) => { test.equal(command, 'FETCH'); // Simulate the FETCH handler pushing two rows options.onUntaggedFetch({ seq: 1, uid: 11 }, () => {}); options.onUntaggedFetch({ seq: 2, uid: 12 }, () => {}); return true; }; let results = []; for await (let msg of client.fetch('1:2', { uid: true })) { results.push(msg); } test.equal(results.length, 2); test.equal(results[0].uid, 11); test.equal(results[1].uid, 12); test.done(); }; module.exports['Fetch: generator breaks early and drains backpressure'] = async test => { let client = makeClient(); let nextCalls = 0; client.run = async (command, range, query, options) => { options.onUntaggedFetch({ seq: 1, uid: 11 }, () => nextCalls++); options.onUntaggedFetch({ seq: 2, uid: 12 }, () => nextCalls++); options.onUntaggedFetch({ seq: 3, uid: 13 }, () => nextCalls++); return true; }; let results = []; for await (let msg of client.fetch('1:3', {})) { results.push(msg); break; // exit early -> finally{} must drain remaining next() callbacks } test.equal(results.length, 1); // The yielded item's next plus the queued items get drained test.ok(nextCalls >= 1); test.done(); }; module.exports['Fetch: generator throws when run rejects'] = async test => { let client = makeClient(); client.run = async () => { throw new Error('FETCH failed'); }; let threw = null; try { // eslint-disable-next-line no-unused-vars for await (let msg of client.fetch('1:2', {})) { // no-op } } catch (err) { threw = err; } test.ok(threw); test.equal(threw.message, 'FETCH failed'); test.done(); }; module.exports['Fetch: generator throws if connection closes mid-iteration'] = async test => { let client = makeClient(); client.run = async (command, range, query, options) => { // queue two rows so a second loop iteration runs after we close the socket options.onUntaggedFetch({ seq: 1, uid: 11 }, () => {}); options.onUntaggedFetch({ seq: 2, uid: 12 }, () => {}); return true; }; let threw = null; try { // eslint-disable-next-line no-unused-vars for await (let msg of client.fetch('1:2', {})) { // close the connection before the next iteration's guard runs client.socket.destroyed = true; } } catch (err) { threw = err; } test.ok(threw); test.equal(threw.code, 'EConnectionClosed'); test.done(); }; // ============================================================================ // fetchOne() / fetchAll() // ============================================================================ module.exports['Fetch: fetchOne returns undefined without mailbox'] = async test => { let client = makeClient(); client.mailbox = false; let res = await client.fetchOne('1', {}); test.equal(res, undefined); test.done(); }; module.exports['Fetch: fetchOne star returns false on empty mailbox'] = async test => { let client = makeClient(); client.mailbox.exists = 0; let res = await client.fetchOne('*', {}); test.equal(res, false); test.done(); }; module.exports['Fetch: fetchOne star uses last message seq'] = async test => { let client = makeClient(); client.mailbox.exists = 7; let captured = null; client.run = async (command, seq, query, options) => { captured = { seq, options }; return { list: [{ seq: 7, uid: 70 }] }; }; let res = await client.fetchOne('*', {}); test.equal(captured.seq, '7'); test.equal(captured.options.uid, false); test.equal(res.uid, 70); test.done(); }; module.exports['Fetch: fetchOne returns false when list empty'] = async test => { let client = makeClient(); client.run = async () => ({ list: [] }); let res = await client.fetchOne('1', {}); test.equal(res, false); test.done(); }; module.exports['Fetch: fetchOne returns first message'] = async test => { let client = makeClient(); client.run = async () => ({ list: [{ seq: 1, uid: 11 }] }); let res = await client.fetchOne('1', { uid: true }); test.equal(res.uid, 11); test.done(); }; module.exports['Fetch: fetchAll collects all generator results'] = async test => { let client = makeClient(); client.run = async (command, range, query, options) => { options.onUntaggedFetch({ seq: 1, uid: 11 }, () => {}); options.onUntaggedFetch({ seq: 2, uid: 12 }, () => {}); return true; }; let all = await client.fetchAll('1:2', {}); test.equal(all.length, 2); test.done(); }; // ============================================================================ // download() // ============================================================================ module.exports['Download: returns empty object without mailbox'] = async test => { let client = makeClient(); client.mailbox = false; let res = await client.download('1'); test.deepEqual(res, {}); test.done(); }; module.exports['Download: full message in multiple chunks'] = async test => { let client = makeClient(); let body = Buffer.from('A'.repeat(10)); client.fetchOne = async (range, query) => { let start = query.source.start; let maxLength = query.source.maxLength; return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) }; }; let { meta, content } = await client.download('1', false, { chunkSize: 4 }); test.equal(meta.contentType, 'message/rfc822'); test.equal(meta.expectedSize, 10); let data = await collect(content); test.equal(data.toString(), 'A'.repeat(10)); test.done(); }; module.exports['Download: falls back to default chunkSize/maxBytes when zero'] = async test => { let client = makeClient(); let body = Buffer.from('tiny'); client.fetchOne = async (range, query) => { let start = query.source.start; let maxLength = query.source.maxLength; return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) }; }; // zero values are falsy -> the (|| default) fallbacks kick in let { content } = await client.download('1', false, { chunkSize: 0, maxBytes: 0 }); let data = await collect(content); test.equal(data.toString(), 'tiny'); test.done(); }; module.exports['Download: flowed pipeline forwards a mid-stream fetch error'] = async test => { let client = makeClient(); let mime = Buffer.from('Content-Type: text/plain; format=flowed\r\nContent-Disposition: inline\r\n\r\n'); let calls = 0; client.fetchOne = async () => { calls++; if (calls === 1) { let bodyParts = new Map(); bodyParts.set('2.mime', mime); bodyParts.set('2', Buffer.alloc(64, 0x61)); return { uid: 1, size: 1000, bodyParts }; } throw new Error('mid-stream flowed fetch fail'); }; let { content } = await client.download('1', '2', { chunkSize: 64 }); let streamErr; content.on('data', () => {}); // stream.finished() fires once with the error (if any) when the stream is // truly done — no end-vs-error resolve race. await new Promise(resolve => finished(content, err => { streamErr = err; resolve(); }) ); test.ok(streamErr, 'error forwarded through the flowed decoder pipeline'); test.done(); }; module.exports['Download: returns {} when fetchOne yields no chunk'] = async test => { let client = makeClient(); client.fetchOne = async () => false; let res = await client.download('1', false, { chunkSize: 4 }); test.deepEqual(res, {}); test.done(); }; module.exports['Download: base64 body part is decoded'] = async test => { let client = makeClient(); let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n'); let encoded = Buffer.from(libbase64.encode(Buffer.from('Hello World'))); client.fetchOne = async () => { let bodyParts = new Map(); bodyParts.set('2.mime', mime); bodyParts.set('2', encoded); return { uid: 1, size: encoded.length, bodyParts }; }; let { meta, content } = await client.download('1', '2', { chunkSize: 1024 }); test.equal(meta.contentType, 'text/plain'); test.equal(meta.encoding, 'base64'); let data = await collect(content); test.equal(data.toString(), 'Hello World'); test.done(); }; module.exports['Download: quoted-printable inline text with charset + filename'] = async test => { let client = makeClient(); let mime = Buffer.from( 'Content-Type: text/plain; charset=iso-8859-1; name="f.txt"\r\n' + 'Content-Transfer-Encoding: quoted-printable\r\n' + 'Content-Disposition: inline; filename="f.txt"\r\n\r\n' ); let encoded = Buffer.from(libqp.encode(Buffer.from('café', 'latin1'))); client.fetchOne = async () => { let bodyParts = new Map(); bodyParts.set('3.mime', mime); bodyParts.set('3', encoded); return { uid: 1, size: encoded.length, bodyParts }; }; let { meta, content } = await client.download('1', '3', { chunkSize: 1024 }); test.equal(meta.encoding, 'quoted-printable'); test.equal(meta.filename, 'f.txt'); // charset is converted to utf-8 for text parts test.equal(meta.charset, 'utf-8'); let data = await collect(content); test.equal(data.toString(), 'café'); test.done(); }; module.exports['Download: part 1 single-node message becomes TEXT'] = async test => { let client = makeClient(); let calls = []; let textBody = Buffer.from('plain text body'); client.fetchOne = async (range, query) => { calls.push(query); if (query.bodyStructure) { // single node, no childNodes return { uid: 1, size: textBody.length, bodyStructure: { type: 'text/plain' } }; } // part has been rewritten to 'text' let bodyParts = new Map(); bodyParts.set('header', Buffer.from('Content-Type: text/plain\r\n\r\n')); bodyParts.set('text', textBody); return { uid: 1, size: textBody.length, bodyParts }; }; let { meta, content } = await client.download('1', '1', { chunkSize: 1024 }); test.ok(meta); let data = await collect(content); test.equal(data.toString(), 'plain text body'); test.done(); }; module.exports['Download: part 1 returns sentinel when bodyStructure fetch fails'] = async test => { let client = makeClient(); client.fetchOne = async () => false; let res = await client.download('1', '1', { chunkSize: 1024 }); test.deepEqual(res, { response: false, chunk: false }); test.done(); }; module.exports['Download: maxBytes limits output'] = async test => { let client = makeClient(); let body = Buffer.from('B'.repeat(20)); client.fetchOne = async (range, query) => { let start = query.source.start; let maxLength = query.source.maxLength; return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) }; }; let { content } = await client.download('1', false, { chunkSize: 4, maxBytes: 6 }); let data = await collect(content); test.ok(data.length <= 6); test.done(); }; module.exports['Download: part 1 with no detectable content-type treated as text'] = async test => { let client = makeClient(); let textBody = Buffer.from('untyped body text'); client.fetchOne = async (range, query) => { if (query.bodyStructure) { return { uid: 1, size: textBody.length, bodyStructure: { type: 'text/plain' } }; // no childNodes } let bodyParts = new Map(); // empty mime header -> no contentType detected -> isTextNode via (part==='1' && !contentType) bodyParts.set('header', Buffer.from('\r\n')); bodyParts.set('text', textBody); return { uid: 1, size: textBody.length, bodyParts }; }; let { content } = await client.download('1', '1', { chunkSize: 1024 }); let data = await collect(content); test.equal(data.toString(), 'untyped body text'); test.done(); }; module.exports['Download: returns {} when requested part missing from response'] = async test => { let client = makeClient(); client.fetchOne = async () => { // response has a mime header but not the actual part content let bodyParts = new Map(); bodyParts.set('5.mime', Buffer.from('Content-Type: text/plain\r\n\r\n')); return { uid: 1, size: 0, bodyParts }; }; let res = await client.download('1', '5', { chunkSize: 1024 }); test.deepEqual(res, {}); test.done(); }; module.exports['Download: format=flowed inline text is decoded'] = async test => { let client = makeClient(); let mime = Buffer.from('Content-Type: text/plain; format=flowed; delsp=yes\r\nContent-Disposition: inline\r\n\r\n'); let body = Buffer.from('soft \r\nbroken\r\n'); client.fetchOne = async () => { let bodyParts = new Map(); bodyParts.set('2.mime', mime); bodyParts.set('2', body); return { uid: 1, size: body.length, bodyParts }; }; let { meta, content } = await client.download('1', '2', { chunkSize: 1024 }); test.ok(meta.flowed, 'flowed flag detected'); let data = await collect(content); test.ok(data.length >= 0); test.done(); }; module.exports['Download: unknown charset is left undecoded'] = async test => { let client = makeClient(); let mime = Buffer.from('Content-Type: text/plain; charset=totally-bogus-xyz\r\nContent-Disposition: inline\r\n\r\n'); let body = Buffer.from('hello bogus charset'); client.fetchOne = async () => { let bodyParts = new Map(); bodyParts.set('2.mime', mime); bodyParts.set('2', body); return { uid: 1, size: body.length, bodyParts }; }; let { meta, content } = await client.download('1', '2', { chunkSize: 1024 }); // getDecoder throws for the bogus charset; charset stays as the original test.equal(meta.charset, 'totally-bogus-xyz'); let data = await collect(content); test.equal(data.toString(), 'hello bogus charset'); test.done(); }; module.exports['Download: stops when a later chunk has no data'] = async test => { let client = makeClient(); let calls = 0; client.fetchOne = async () => { calls++; if (calls <= 3) { // full-size chunks keep hasMore true return { uid: 1, size: 100, source: Buffer.from('ABCD') }; } // subsequent fetch returns no source -> getNextPart returns {} -> loop breaks return { uid: 1, size: 100, source: null }; }; let { content } = await client.download('1', false, { chunkSize: 4 }); let data = await collect(content); test.equal(data.toString(), 'ABCDABCDABCD'); test.done(); }; module.exports['Download: initial chunk waits for drain when buffer full'] = async test => { let client = makeClient(); let big = Buffer.alloc(64 * 1024, 0x61); // 64KB > default 16KB highWaterMark let calls = 0; client.fetchOne = async () => { calls++; if (calls <= 2) { return { uid: 1, size: 200 * 1024, source: big }; } return { uid: 1, size: 200 * 1024, source: big.slice(0, 10) }; }; let { content } = await client.download('1', false, { chunkSize: 64 * 1024 }); // Do NOT consume yet: with no consumer the first 64KB write returns false, // so download registers a 'drain' listener and waits before fetching more. await new Promise(resolve => setTimeout(resolve, 25)); // Now drain it. let received = 0; content.on('data', c => { received += c.length; }); await new Promise(resolve => content.on('end', resolve)); test.ok(received > 64 * 1024); test.done(); }; module.exports['Download: in-loop backpressure waits for drain'] = async test => { let client = makeClient(); let big = Buffer.alloc(64 * 1024, 0x61); let calls = 0; client.fetchOne = async () => { calls++; if (calls <= 4) { return { uid: 1, size: 400 * 1024, source: big }; } return { uid: 1, size: 400 * 1024, source: big.slice(0, 10) }; }; let { content } = await client.download('1', false, { chunkSize: 64 * 1024 }); // A slow consumer attached immediately. The first write succeeds (data moves // one buffer down) but subsequent loop writes return false while the consumer // is still draining the previous chunk, exercising the in-loop drain wait. let received = 0; let slow = new Writable({ highWaterMark: 1, write(chunk, enc, cb) { received += chunk.length; setTimeout(cb, 40); } }); content.pipe(slow); await new Promise(resolve => slow.on('finish', resolve)); test.ok(received > 64 * 1024); test.done(); }; module.exports['Download: error during streaming surfaces on content stream'] = async test => { let client = makeClient(); let calls = 0; client.fetchOne = async () => { calls++; if (calls === 1) { // metadata chunk (full size keeps hasMore true) return { uid: 1, size: 100, source: Buffer.alloc(4, 0x61) }; } // subsequent loop fetch fails -> fetchAllParts rejects -> error on stream throw new Error('mid-stream fetch failure'); }; let { content } = await client.download('1', false, { chunkSize: 4 }); let streamErr; content.on('data', () => {}); await new Promise(resolve => finished(content, err => { streamErr = err; resolve(); }) ); test.ok(streamErr, 'error surfaced on content stream'); test.equal(streamErr.message, 'mid-stream fetch failure'); test.done(); }; module.exports['Download: write error on initial chunk surfaces on stream'] = async test => { let client = makeClient(); // a non-Buffer source makes stream.write throw inside the setImmediate try/catch client.fetchOne = async () => ({ uid: 1, size: 100, source: 999999 }); let { content } = await client.download('1', false, { chunkSize: 4 }); let streamErr; content.on('data', () => {}); await new Promise(resolve => finished(content, err => { streamErr = err; resolve(); }) ); test.ok(streamErr, 'write error surfaced'); test.done(); }; module.exports['Download: destroying content before streaming aborts cleanly'] = async test => { let client = makeClient(); let big = Buffer.alloc(64 * 1024, 0x61); client.fetchOne = async (range, query) => { let start = query.source.start; return { uid: 1, size: 500 * 1024, source: big.slice(start, start + 64 * 1024) }; }; let { content } = await client.download('1', false, { chunkSize: 64 * 1024 }); // Destroy immediately: the deferred writeChunk sees a destroyed stream and bails. content.destroy(); await new Promise(resolve => setTimeout(resolve, 30)); test.ok(true, 'no crash when content destroyed before streaming starts'); test.done(); }; module.exports['Download: aborting mid-backpressure stops the fetch loop'] = async test => { let client = makeClient(); let big = Buffer.alloc(64 * 1024, 0x61); let calls = 0; client.fetchOne = async () => { calls++; if (calls <= 6) { return { uid: 1, size: 1000 * 1024, source: big }; } return { uid: 1, size: 1000 * 1024, source: big.slice(0, 10) }; }; let { content } = await client.download('1', false, { chunkSize: 64 * 1024 }); // Slow consumer to force backpressure, then destroy mid-stream so the loop // aborts during a drain wait. let slow = new Writable({ highWaterMark: 1, write(chunk, enc, cb) { setTimeout(cb, 50); } }); content.pipe(slow); setTimeout(() => content.destroy(), 70); await new Promise(resolve => { slow.on('finish', resolve); content.on('close', resolve); content.on('error', resolve); }); test.ok(true, 'aborted mid-backpressure without hanging'); test.done(); }; // ============================================================================ // downloadMany() // ============================================================================ module.exports['DownloadMany: returns empty object without mailbox'] = async test => { let client = makeClient(); client.mailbox = false; let res = await client.downloadMany('1', ['2']); test.deepEqual(res, {}); test.done(); }; module.exports['DownloadMany: returns {response:false} when no bodyParts'] = async test => { let client = makeClient(); client.fetchOne = async () => ({ uid: 1 }); let res = await client.downloadMany('1', ['2']); test.deepEqual(res, { response: false }); test.done(); }; module.exports['DownloadMany: parses charset, flowed and name params'] = async test => { let client = makeClient(); let mime = Buffer.from( 'Content-Type: text/plain; charset=iso-8859-1; format=flowed; delsp=yes; name="x.txt"\r\n' + 'Content-Disposition: inline\r\n\r\n' ); client.fetchOne = async () => { let bodyParts = new Map(); bodyParts.set('2.mime', mime); bodyParts.set('2', Buffer.from('plain')); return { uid: 1, bodyParts }; }; let res = await client.downloadMany('1', ['2']); test.equal(res['2'].meta.charset, 'iso-8859-1'); test.equal(res['2'].meta.flowed, true); test.equal(res['2'].meta.delSp, true); test.equal(res['2'].meta.filename, 'x.txt'); test.done(); }; module.exports['DownloadMany: tolerates decodeWords failures for disposition/filename'] = async test => { let originalDecodeWords = libmime.decodeWords; libmime.decodeWords = () => { throw new Error('decodeWords boom'); }; try { let client = makeClient(); let mime = Buffer.from('Content-Type: text/plain\r\nContent-Disposition: attachment; filename="f.txt"\r\n\r\n'); client.fetchOne = async () => { let bodyParts = new Map(); bodyParts.set('2.mime', mime); bodyParts.set('2', Buffer.from('x')); return { uid: 1, bodyParts }; }; let res = await client.downloadMany('1', ['2']); // disposition/filename are kept as-is when decodeWords throws test.equal(res['2'].meta.disposition, 'attachment'); test.equal(res['2'].meta.filename, 'f.txt'); } finally { libmime.decodeWords = originalDecodeWords; } test.done(); }; module.exports['Download: tolerates decodeWords failures for disposition/filename'] = async test => { let originalDecodeWords = libmime.decodeWords; libmime.decodeWords = () => { throw new Error('decodeWords boom'); }; try { let client = makeClient(); let mime = Buffer.from('Content-Type: text/plain\r\nContent-Disposition: attachment; filename="f.txt"\r\n\r\n'); client.fetchOne = async () => { let bodyParts = new Map(); bodyParts.set('2.mime', mime); bodyParts.set('2', Buffer.from('hello')); return { uid: 1, size: 5, bodyParts }; }; let { meta, content } = await client.download('1', '2', { chunkSize: 1024 }); test.equal(meta.disposition, 'attachment'); test.equal(meta.filename, 'f.txt'); await collect(content); } finally { libmime.decodeWords = originalDecodeWords; } test.done(); }; module.exports['DownloadMany: encoded parts with no content decode to null'] = async test => { let client = makeClient(); let mime2 = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n'); let mime3 = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n'); client.fetchOne = async () => { let bodyParts = new Map(); // only the mime headers, no actual content parts -> content stays undefined bodyParts.set('2.mime', mime2); bodyParts.set('3.mime', mime3); return { uid: 1, bodyParts }; }; let res = await client.downloadMany('1', ['2', '3']); test.equal(res['2'].content, null); // base64 decode of missing content -> null test.equal(res['3'].content, null); // quoted-printable decode of missing content -> null test.done(); }; module.exports['DownloadMany: handles content part arriving before its mime header'] = async test => { let client = makeClient(); client.fetchOne = async () => { let bodyParts = new Map(); bodyParts.set('2', Buffer.from('hello')); // content first bodyParts.set('2.mime', Buffer.from('Content-Type: text/plain\r\n\r\n')); return { uid: 1, bodyParts }; }; let res = await client.downloadMany('1', ['2']); test.equal(res['2'].content.toString(), 'hello'); test.equal(res['2'].meta.contentType, 'text/plain'); test.done(); }; module.exports['Download: charset pipeline forwards a mid-stream fetch error'] = async test => { let client = makeClient(); let mime = Buffer.from('Content-Type: text/plain; charset=iso-8859-1\r\nContent-Disposition: inline\r\n\r\n'); let calls = 0; client.fetchOne = async () => { calls++; if (calls === 1) { let bodyParts = new Map(); bodyParts.set('2.mime', mime); bodyParts.set('2', Buffer.alloc(64, 0x61)); // full chunk -> hasMore return { uid: 1, size: 1000, bodyParts }; } throw new Error('mid-stream charset fetch fail'); }; let { content } = await client.download('1', '2', { chunkSize: 64 }); let streamErr; content.on('data', () => {}); await new Promise(resolve => finished(content, err => { streamErr = err; resolve(); }) ); test.ok(streamErr, 'error forwarded through the charset decoder pipeline'); test.done(); }; // Covers the `(part === '1' && !meta.contentType)` text-node branch: for a // MULTIPART message, part '1' is NOT rewritten to TEXT, so requesting part '1' // whose MIME headers carry no Content-Type reaches that sub-condition. module.exports['Download: multipart part 1 without content-type streams as a text node'] = async test => { let client = makeClient(); client.fetchOne = async (range, query) => { if (query.bodyStructure) { // childNodes present -> multipart -> part '1' stays '1' return { uid: 1, size: 11, bodyStructure: { type: 'multipart/mixed', childNodes: [{ type: 'text/plain' }, { type: 'application/octet-stream' }] } }; } let bodyParts = new Map(); // MIME headers with no Content-Type -> meta.contentType stays unset bodyParts.set('1.mime', Buffer.from('\r\n')); bodyParts.set('1', Buffer.from('hello world')); return { uid: 1, size: 11, bodyParts }; }; let { content } = await client.download('1', '1', { chunkSize: 64 }); let data = await collect(content); test.equal(data.toString(), 'hello world'); test.done(); }; module.exports['DownloadMany: decodes base64 and quoted-printable parts'] = async test => { let client = makeClient(); let mime2 = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n'); let mime3 = Buffer.from( 'Content-Type: application/octet-stream; name="a.bin"\r\n' + 'Content-Transfer-Encoding: quoted-printable\r\n' + 'Content-Disposition: attachment; filename="a.bin"\r\n\r\n' ); client.fetchOne = async (range, query) => { test.ok(query.bodyParts.includes('2.mime')); let bodyParts = new Map(); bodyParts.set('2.mime', mime2); bodyParts.set('2', Buffer.from(libbase64.encode(Buffer.from('part two')))); bodyParts.set('3.mime', mime3); bodyParts.set('3', Buffer.from(libqp.encode(Buffer.from('part three')))); return { uid: 1, bodyParts }; }; let res = await client.downloadMany('1', ['2', '3']); test.equal(res['2'].content.toString(), 'part two'); test.equal(res['2'].meta.contentType, 'text/plain'); test.equal(res['3'].content.toString(), 'part three'); test.equal(res['3'].meta.filename, 'a.bin'); test.equal(res['3'].meta.disposition, 'attachment'); test.done(); };