imapflow
Version:
IMAP Client for Node
739 lines (656 loc) • 25.2 kB
JavaScript
'use strict';
// Unit tests that exercise ImapFlow public/command methods against a real
// ImapFlow instance with `run` (and occasionally lower-level helpers) stubbed.
// This deterministically covers the thin command wrappers, range resolution,
// the untagged response handlers, and the fetch/download orchestration without
// requiring a live IMAP server.
const { ImapFlow } = require('../lib/imap-flow');
// Build a real ImapFlow instance with logging disabled and a selected mailbox.
// `run` is stubbed by default to record calls and return a configurable value.
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,
flags: new Set(['\\Seen', '\\Flagged']),
permanentFlags: new Set(['\\*'])
};
client.state = client.states.SELECTED;
return client;
};
// Records every run() invocation so assertions can verify the dispatched command.
const recordRun = (client, returnValue) => {
let calls = [];
client.run = async (...args) => {
calls.push(args);
return typeof returnValue === 'function' ? returnValue(...args) : returnValue;
};
return calls;
};
// ============================================================================
// Thin command wrappers
// ============================================================================
module.exports['Methods: getQuota defaults path to INBOX'] = async test => {
let client = makeClient();
let calls = recordRun(client, { path: 'INBOX' });
let res = await client.getQuota();
test.deepEqual(calls[0], ['QUOTA', 'INBOX']);
test.deepEqual(res, { path: 'INBOX' });
test.done();
};
module.exports['Methods: getQuota uses given path'] = async test => {
let client = makeClient();
let calls = recordRun(client, false);
await client.getQuota('Archive');
test.deepEqual(calls[0], ['QUOTA', 'Archive']);
test.done();
};
module.exports['Methods: list builds folders map'] = async test => {
let client = makeClient();
recordRun(client, [
{ path: 'INBOX', name: 'INBOX' },
{ path: 'Sent', name: 'Sent' }
]);
let folders = await client.list();
test.equal(folders.length, 2);
test.ok(client.folders instanceof Map);
test.equal(client.folders.get('Sent').name, 'Sent');
test.done();
};
module.exports['Methods: list passes options'] = async test => {
let client = makeClient();
let calls = recordRun(client, []);
await client.list({ statusQuery: { messages: true } });
test.deepEqual(calls[0][0], 'LIST');
test.deepEqual(calls[0][3], { statusQuery: { messages: true } });
test.done();
};
module.exports['Methods: listTree returns tree structure'] = async test => {
let client = makeClient();
recordRun(client, [
{ path: 'INBOX', name: 'INBOX', delimiter: '/', parent: [], flags: new Set() },
{ path: 'INBOX/Sub', name: 'Sub', delimiter: '/', parent: ['INBOX'], flags: new Set() }
]);
let tree = await client.listTree();
test.ok(tree);
test.ok(Array.isArray(tree.folders));
test.ok(client.folders instanceof Map);
test.done();
};
module.exports['Methods: noop dispatches NOOP'] = async test => {
let client = makeClient();
let calls = recordRun(client, true);
await client.noop();
test.deepEqual(calls[0], ['NOOP']);
test.done();
};
module.exports['Methods: mailboxCreate dispatches CREATE'] = async test => {
let client = makeClient();
let calls = recordRun(client, { path: 'New', created: true });
let res = await client.mailboxCreate(['parent', 'child']);
test.deepEqual(calls[0], ['CREATE', ['parent', 'child']]);
test.equal(res.created, true);
test.done();
};
module.exports['Methods: mailboxRename dispatches RENAME'] = async test => {
let client = makeClient();
let calls = recordRun(client, { path: 'a', newPath: 'b' });
await client.mailboxRename('a', 'b');
test.deepEqual(calls[0], ['RENAME', 'a', 'b']);
test.done();
};
module.exports['Methods: mailboxDelete dispatches DELETE'] = async test => {
let client = makeClient();
let calls = recordRun(client, { path: 'a' });
await client.mailboxDelete('a');
test.deepEqual(calls[0], ['DELETE', 'a']);
test.done();
};
module.exports['Methods: mailboxSubscribe dispatches SUBSCRIBE'] = async test => {
let client = makeClient();
let calls = recordRun(client, true);
await client.mailboxSubscribe('a');
test.deepEqual(calls[0], ['SUBSCRIBE', 'a']);
test.done();
};
module.exports['Methods: mailboxUnsubscribe dispatches UNSUBSCRIBE'] = async test => {
let client = makeClient();
let calls = recordRun(client, true);
await client.mailboxUnsubscribe('a');
test.deepEqual(calls[0], ['UNSUBSCRIBE', 'a']);
test.done();
};
module.exports['Methods: mailboxOpen dispatches SELECT'] = async test => {
let client = makeClient();
let calls = recordRun(client, { path: 'INBOX' });
await client.mailboxOpen('INBOX', { readOnly: true });
test.deepEqual(calls[0], ['SELECT', 'INBOX', { readOnly: true }]);
test.done();
};
module.exports['Methods: mailboxClose dispatches CLOSE'] = async test => {
let client = makeClient();
let calls = recordRun(client, true);
await client.mailboxClose();
test.deepEqual(calls[0], ['CLOSE']);
test.done();
};
module.exports['Methods: status dispatches STATUS'] = async test => {
let client = makeClient();
let calls = recordRun(client, { path: 'INBOX', unseen: 3 });
let res = await client.status('INBOX', { unseen: true });
test.deepEqual(calls[0], ['STATUS', 'INBOX', { unseen: true }]);
test.equal(res.unseen, 3);
test.done();
};
// ============================================================================
// idle()
// ============================================================================
module.exports['Methods: idle runs IDLE when not already idling'] = async test => {
let client = makeClient();
let calls = recordRun(client, true);
await client.idle();
test.deepEqual(calls[0], ['IDLE', client.maxIdleTime]);
test.done();
};
module.exports['Methods: idle is a no-op when already idling'] = async test => {
let client = makeClient();
client.idling = true;
let calls = recordRun(client, true);
let res = await client.idle();
test.equal(calls.length, 0);
test.equal(res, undefined);
test.done();
};
// ============================================================================
// Flag operations + range resolution
// ============================================================================
module.exports['Methods: messageFlagsSet resolves range and dispatches STORE'] = async test => {
let client = makeClient();
let calls = recordRun(client, true);
let res = await client.messageFlagsSet('1:5', ['\\Seen'], { uid: true });
test.equal(res, true);
test.equal(calls[0][0], 'STORE');
test.equal(calls[0][1], '1:5');
test.deepEqual(calls[0][3], { operation: 'set', uid: true });
test.done();
};
module.exports['Methods: messageFlagsSet works without an options argument'] = async test => {
let client = makeClient();
let calls = recordRun(client, true);
let res = await client.messageFlagsSet('1', ['\\Seen']); // no options -> options || {}
test.equal(res, true);
test.equal(calls[0][0], 'STORE');
test.done();
};
module.exports['Methods: search derivation handles non-string returnOptions entries'] = async test => {
let client = makeClient();
recordRun(client, [1, 2, 3]); // no ESEARCH -> client-side derivation runs
// a non-string returnOption (object) is passed through untouched by the mapper
let res = await client.search({ all: true }, { returnOptions: ['count', { partial: '1:2' }] });
test.equal(res.count, 3);
test.done();
};
module.exports['Methods: messageFlagsSet returns false for empty range'] = async test => {
let client = makeClient();
let calls = recordRun(client, true);
let res = await client.messageFlagsSet([], ['\\Seen'], {});
test.equal(res, false);
test.equal(calls.length, 0);
test.done();
};
module.exports['Methods: messageFlagsAdd dispatches STORE add'] = async test => {
let client = makeClient();
let calls = recordRun(client, true);
await client.messageFlagsAdd('1', ['\\Flagged']);
test.equal(calls[0][3].operation, 'add');
test.done();
};
module.exports['Methods: messageFlagsAdd returns false on empty range'] = async test => {
let client = makeClient();
recordRun(client, true);
let res = await client.messageFlagsAdd([], ['\\Flagged']);
test.equal(res, false);
test.done();
};
module.exports['Methods: messageFlagsRemove dispatches STORE remove'] = async test => {
let client = makeClient();
let calls = recordRun(client, true);
await client.messageFlagsRemove('1', ['\\Flagged']);
test.equal(calls[0][3].operation, 'remove');
test.done();
};
module.exports['Methods: messageFlagsRemove returns false on empty range'] = async test => {
let client = makeClient();
recordRun(client, true);
let res = await client.messageFlagsRemove([], ['\\Flagged']);
test.equal(res, false);
test.done();
};
module.exports['Methods: setFlagColor returns false for empty range'] = async test => {
let client = makeClient();
recordRun(client, true);
let res = await client.setFlagColor([], 'red');
test.equal(res, false);
test.done();
};
module.exports['Methods: setFlagColor returns false for invalid color'] = async test => {
let client = makeClient();
recordRun(client, true);
let res = await client.setFlagColor('1', 'not-a-color');
test.equal(res, false);
test.done();
};
module.exports['Methods: setFlagColor red issues add and remove STORE'] = async test => {
let client = makeClient();
let calls = recordRun(client, true);
// red sets \\Flagged and clears the MailFlagBit* keywords -> both add and remove
let res = await client.setFlagColor('1:*', 'red');
test.ok(res);
let ops = calls.map(c => c[3].operation);
test.ok(ops.includes('add'));
test.ok(ops.includes('remove'));
test.done();
};
module.exports['Methods: setFlagColor removal (null color) issues only a remove STORE'] = async test => {
test.expect(5);
let client = makeClient();
let calls = recordRun(client, true);
// A null/empty color clears the color: getColorFlags returns an empty add set and a
// non-empty remove set, so the add STORE is skipped and only the remove STORE runs.
let res = await client.setFlagColor('1', null);
test.ok(res);
test.equal(calls.length, 1);
test.equal(calls[0][0], 'STORE');
test.equal(calls[0][3].operation, 'remove');
test.deepEqual(calls[0][2], ['\\Flagged', '$MailFlagBit0', '$MailFlagBit1', '$MailFlagBit2']);
test.done();
};
module.exports['Methods: messageDelete resolves range and dispatches EXPUNGE'] = async test => {
let client = makeClient();
let calls = recordRun(client, true);
let res = await client.messageDelete('1:3', { uid: true });
test.equal(res, true);
test.equal(calls[0][0], 'EXPUNGE');
test.equal(calls[0][1], '1:3');
test.done();
};
module.exports['Methods: messageDelete returns false on empty range'] = async test => {
let client = makeClient();
recordRun(client, true);
let res = await client.messageDelete([]);
test.equal(res, false);
test.done();
};
// ============================================================================
// append / copy / move
// ============================================================================
module.exports['Methods: append dispatches APPEND'] = async test => {
let client = makeClient();
let calls = recordRun(client, { destination: 'INBOX', uid: 5 });
let res = await client.append('INBOX', 'raw message', ['\\Seen'], new Date(2020, 1, 1));
test.equal(calls[0][0], 'APPEND');
test.equal(res.uid, 5);
test.done();
};
module.exports['Methods: append returns false when run returns falsy'] = async test => {
let client = makeClient();
recordRun(client, false);
let res = await client.append('INBOX', 'raw');
test.equal(res, false);
test.done();
};
module.exports['Methods: messageCopy resolves range and dispatches COPY'] = async test => {
let client = makeClient();
let calls = recordRun(client, { destination: 'Backup' });
await client.messageCopy('1:*', 'Backup', { uid: false });
test.equal(calls[0][0], 'COPY');
test.equal(calls[0][2], 'Backup');
test.done();
};
module.exports['Methods: messageCopy returns false on empty range'] = async test => {
let client = makeClient();
recordRun(client, true);
let res = await client.messageCopy([], 'Backup');
test.equal(res, false);
test.done();
};
module.exports['Methods: messageMove resolves range and dispatches MOVE'] = async test => {
let client = makeClient();
let calls = recordRun(client, { destination: 'Trash' });
await client.messageMove('1', 'Trash');
test.equal(calls[0][0], 'MOVE');
test.equal(calls[0][2], 'Trash');
test.done();
};
module.exports['Methods: messageMove returns false on empty range'] = async test => {
let client = makeClient();
recordRun(client, true);
let res = await client.messageMove([], 'Trash');
test.equal(res, false);
test.done();
};
// ============================================================================
// resolveRange branches
// ============================================================================
module.exports['Methods: resolveRange converts number'] = async test => {
let client = makeClient();
let res = await client.resolveRange(5, {});
test.equal(res, '5');
test.done();
};
module.exports['Methods: resolveRange converts bigint'] = async test => {
let client = makeClient();
let res = await client.resolveRange(BigInt(7), {});
test.equal(res, '7');
test.done();
};
module.exports['Methods: resolveRange star uses mailbox.exists'] = async test => {
let client = makeClient();
let options = {};
let res = await client.resolveRange('*', options);
test.equal(res, '10');
test.equal(options.uid, false);
test.done();
};
module.exports['Methods: resolveRange star returns false on empty mailbox'] = async test => {
let client = makeClient();
client.mailbox.exists = 0;
let res = await client.resolveRange('*', {});
test.equal(res, false);
test.done();
};
module.exports['Methods: resolveRange all:true => 1:*'] = async test => {
let client = makeClient();
let res = await client.resolveRange({ all: true }, {});
test.equal(res, '1:*');
test.done();
};
module.exports['Methods: resolveRange uid object'] = async test => {
let client = makeClient();
let options = {};
let res = await client.resolveRange({ uid: '100:200' }, options);
test.equal(res, '100:200');
test.equal(options.uid, true);
test.done();
};
module.exports['Methods: resolveRange search query runs SEARCH and packs'] = async test => {
let client = makeClient();
let options = {};
client.run = async (cmd, query, opts) => {
test.equal(cmd, 'SEARCH');
test.equal(opts.uid, true);
return [1, 2, 3, 5];
};
let res = await client.resolveRange({ seen: false }, options);
test.equal(res, '1:3,5');
test.done();
};
module.exports['Methods: resolveRange search query with no hits'] = async test => {
let client = makeClient();
client.run = async () => [];
let res = await client.resolveRange({ seen: false }, {});
test.equal(res, false);
test.done();
};
module.exports['Methods: resolveRange array joins with commas'] = async test => {
let client = makeClient();
let res = await client.resolveRange([1, 2, 4], {});
test.equal(res, '1,2,4');
test.done();
};
module.exports['Methods: resolveRange empty string returns false'] = async test => {
let client = makeClient();
let res = await client.resolveRange('', {});
test.equal(res, false);
test.done();
};
// ============================================================================
// ensureSelectedMailbox
// ============================================================================
module.exports['Methods: ensureSelectedMailbox returns false without path'] = async test => {
let client = makeClient();
let res = await client.ensureSelectedMailbox(null);
test.equal(res, false);
test.done();
};
module.exports['Methods: ensureSelectedMailbox returns true when already selected'] = async test => {
let client = makeClient();
let res = await client.ensureSelectedMailbox('INBOX');
test.equal(res, true);
test.done();
};
module.exports['Methods: ensureSelectedMailbox opens a different mailbox'] = async test => {
let client = makeClient();
let calls = recordRun(client, { path: 'Other' });
await client.ensureSelectedMailbox('Other');
test.equal(calls[0][0], 'SELECT');
test.equal(calls[0][1], 'Other');
test.done();
};
// ============================================================================
// search()
// ============================================================================
module.exports['Methods: search returns undefined without mailbox'] = async test => {
let client = makeClient();
client.mailbox = false;
let res = await client.search({ seen: false });
test.equal(res, undefined);
test.done();
};
module.exports['Methods: search returns server result'] = async test => {
let client = makeClient();
recordRun(client, [1, 2, 3]);
let res = await client.search({ seen: false });
test.deepEqual(res, [1, 2, 3]);
test.done();
};
module.exports['Methods: search returns false when run returns falsy'] = async test => {
let client = makeClient();
recordRun(client, null);
let res = await client.search({ all: true });
test.equal(res, false);
test.done();
};
module.exports['Methods: search derives ESearch COUNT/MIN/MAX/ALL client-side'] = async test => {
let client = makeClient();
recordRun(client, [2, 4, 6, 8]);
let res = await client.search({ all: true }, { returnOptions: ['count', 'min', 'max', 'all'] });
test.equal(res.count, 4);
test.equal(res.min, 2);
test.equal(res.max, 8);
test.equal(res.all, '2,4,6,8');
test.done();
};
module.exports['Methods: search returnOptions PARTIAL only returns raw array'] = async test => {
let client = makeClient();
recordRun(client, [1, 2, 3]);
let res = await client.search({ all: true }, { returnOptions: ['partial'] });
test.deepEqual(res, [1, 2, 3]);
test.done();
};
// ============================================================================
// Untagged response handlers
// ============================================================================
module.exports['Methods: untaggedExists ignores when no mailbox'] = async test => {
let client = makeClient();
client.mailbox = false;
await client.untaggedExists({ command: '12' });
test.ok(true);
test.done();
};
module.exports['Methods: untaggedExists ignores invalid command'] = async test => {
let client = makeClient();
let fired = false;
client.on('exists', () => {
fired = true;
});
await client.untaggedExists({ command: 'NaNvalue' });
test.equal(fired, false);
test.done();
};
module.exports['Methods: untaggedExists no-op when count unchanged'] = async test => {
let client = makeClient();
client.mailbox.exists = 10;
let fired = false;
client.on('exists', () => {
fired = true;
});
await client.untaggedExists({ command: '10' });
test.equal(fired, false);
test.done();
};
module.exports['Methods: untaggedExists emits exists event on change'] = async test => {
let client = makeClient();
client.mailbox.exists = 10;
let evt = null;
client.on('exists', e => {
evt = e;
});
await client.untaggedExists({ command: '12' });
test.ok(evt);
test.equal(evt.count, 12);
test.equal(evt.prevCount, 10);
test.equal(client.mailbox.exists, 12);
test.done();
};
module.exports['Methods: untaggedExpunge ignores when no mailbox'] = async test => {
let client = makeClient();
client.mailbox = false;
await client.untaggedExpunge({ command: '2' });
test.ok(true);
test.done();
};
module.exports['Methods: untaggedExpunge ignores invalid command'] = async test => {
let client = makeClient();
let fired = false;
client.on('expunge', () => {
fired = true;
});
await client.untaggedExpunge({ command: 'x' });
test.equal(fired, false);
test.done();
};
module.exports['Methods: untaggedExpunge emits expunge event'] = async test => {
let client = makeClient();
client.mailbox.exists = 10;
let evt = null;
client.on('expunge', e => {
evt = e;
});
await client.untaggedExpunge({ command: '3' });
test.ok(evt);
test.equal(evt.seq, 3);
test.equal(evt.vanished, false);
test.equal(client.mailbox.exists, 9);
test.done();
};
module.exports['Methods: untaggedExpunge uses expungeHandler when set'] = async test => {
let payloads = [];
let client = makeClient({ expungeHandler: async payload => payloads.push(payload) });
client.mailbox.exists = 5;
await client.untaggedExpunge({ command: '2' });
test.equal(payloads.length, 1);
test.equal(payloads[0].seq, 2);
test.done();
};
module.exports['Methods: untaggedExpunge handles expungeHandler error'] = async test => {
let client = makeClient({
expungeHandler: async () => {
throw new Error('handler boom');
}
});
client.mailbox.exists = 5;
// Should not throw despite handler error
await client.untaggedExpunge({ command: '2' });
test.ok(true);
test.done();
};
module.exports['Methods: untaggedVanished ignores when no mailbox'] = async test => {
let client = makeClient();
client.mailbox = false;
await client.untaggedVanished({ attributes: [{ value: '1:3' }] });
test.ok(true);
test.done();
};
module.exports['Methods: untaggedVanished emits expunge per uid'] = async test => {
let client = makeClient();
let events = [];
client.on('expunge', e => events.push(e));
await client.untaggedVanished({ attributes: [{ value: '1:3' }] });
test.equal(events.length, 3);
test.equal(events[0].uid, 1);
test.equal(events[0].vanished, true);
test.equal(events[0].earlier, false);
test.done();
};
module.exports['Methods: untaggedVanished EARLIER tag sets earlier flag'] = async test => {
let client = makeClient();
let events = [];
client.on('expunge', e => events.push(e));
await client.untaggedVanished({
attributes: [[{ value: 'EARLIER' }], { value: '5' }]
});
test.equal(events.length, 1);
test.equal(events[0].uid, 5);
test.equal(events[0].earlier, true);
test.done();
};
module.exports['Methods: untaggedVanished routes through expungeHandler'] = async test => {
let payloads = [];
let client = makeClient({ expungeHandler: async p => payloads.push(p) });
await client.untaggedVanished({ attributes: [{ value: '7' }] });
test.equal(payloads.length, 1);
test.equal(payloads[0].uid, 7);
test.done();
};
module.exports['Methods: untaggedVanished swallows expungeHandler error'] = async test => {
let client = makeClient({
expungeHandler: async () => {
throw new Error('vanished boom');
}
});
await client.untaggedVanished({ attributes: [{ value: '7' }] });
test.ok(true);
test.done();
};
module.exports['Methods: untaggedFetch ignores when no mailbox'] = async test => {
let client = makeClient();
client.mailbox = false;
await client.untaggedFetch({ command: '1', attributes: [] });
test.ok(true);
test.done();
};
module.exports['Methods: untaggedFetch emits flags event'] = async test => {
let client = makeClient();
let evt = null;
client.on('flags', e => {
evt = e;
});
// Build an untagged FETCH with FLAGS and UID attributes
let untagged = {
command: '1',
attributes: [
{ type: 'ATOM', value: 'FETCH' },
[
{ type: 'ATOM', value: 'UID' },
{ type: 'ATOM', value: '100' },
{ type: 'ATOM', value: 'FLAGS' },
[{ type: 'ATOM', value: '\\Seen' }]
]
]
};
await client.untaggedFetch(untagged);
test.ok(evt);
test.equal(evt.uid, 100);
test.ok(evt.flags instanceof Set);
test.ok(evt.flags.has('\\Seen'));
test.done();
};