UNPKG

imapflow

Version:

IMAP Client for Node

303 lines (278 loc) 11.8 kB
'use strict'; const searchCmd = require('../lib/commands/search'); const { parseEsearchResponse } = searchCmd; const { ImapFlow } = require('../lib/imap-flow'); // Mock connection — capabilities is Map (matches real ImapFlow) function makeConnection({ hasEsearch = true } = {}) { const caps = new Map(); if (hasEsearch) { caps.set('ESEARCH', true); } return { state: 'SELECTED', states: { SELECTED: 'SELECTED' }, capabilities: caps, exec: async () => ({ next: () => {} }), log: { warn: () => {} } }; } // ── Parser tests ─────────────────────────────────────────────────────────── module.exports['ESEARCH: parseEsearchResponse COUNT only'] = test => { const attrs = [ { type: 'ATOM', value: 'COUNT' }, { type: 'ATOM', value: '42' } ]; const result = parseEsearchResponse(attrs); test.equal(result.count, 42); test.equal(result.min, undefined); test.equal(result.max, undefined); test.done(); }; module.exports['ESEARCH: parseEsearchResponse MIN MAX'] = test => { const attrs = [ { type: 'ATOM', value: 'MIN' }, { type: 'ATOM', value: '1001' }, { type: 'ATOM', value: 'MAX' }, { type: 'ATOM', value: '9876' } ]; const result = parseEsearchResponse(attrs); test.equal(result.min, 1001); test.equal(result.max, 9876); test.done(); }; module.exports['ESEARCH: parseEsearchResponse ALL keeps compact string'] = test => { const attrs = [ { type: 'ATOM', value: 'ALL' }, { type: 'ATOM', value: '1001,1005:1010,1020' } ]; const result = parseEsearchResponse(attrs); // Must be preserved as compact string — NOT an array test.equal(typeof result.all, 'string'); test.equal(result.all, '1001,1005:1010,1020'); test.done(); }; module.exports['ESEARCH: parseEsearchResponse PARTIAL (Array form)'] = test => { // Parser represents parenthesized groups as plain Arrays const attrs = [ { type: 'ATOM', value: 'PARTIAL' }, [ { type: 'ATOM', value: '1:100' }, { type: 'ATOM', value: '1001,1003:1010,1015' } ] ]; const result = parseEsearchResponse(attrs); test.deepEqual(result.partial, { range: '1:100', messages: '1001,1003:1010,1015' }); test.done(); }; module.exports['ESEARCH: parseEsearchResponse PARTIAL (LIST object form)'] = test => { // Also handle {type: 'LIST', attributes: [...]} form for robustness const attrs = [ { type: 'ATOM', value: 'PARTIAL' }, { type: 'LIST', attributes: [ { type: 'ATOM', value: '1:100' }, { type: 'ATOM', value: '1001,1003:1010,1015' } ] } ]; const result = parseEsearchResponse(attrs); test.deepEqual(result.partial, { range: '1:100', messages: '1001,1003:1010,1015' }); test.done(); }; module.exports['ESEARCH: parseEsearchResponse COUNT + PARTIAL combined'] = test => { const attrs = [ { type: 'ATOM', value: 'COUNT' }, { type: 'ATOM', value: '34201' }, { type: 'ATOM', value: 'PARTIAL' }, [ { type: 'ATOM', value: '1:100' }, { type: 'ATOM', value: '2001,2003:2020' } ] ]; const result = parseEsearchResponse(attrs); test.equal(result.count, 34201); test.deepEqual(result.partial, { range: '1:100', messages: '2001,2003:2020' }); test.done(); }; // ── Command-building tests ───────────────────────────────────────────────── module.exports['ESEARCH: emits RETURN clause when returnOptions present and server has ESEARCH'] = test => { const conn = makeConnection({ hasEsearch: true }); let capturedCommand = null; let capturedAttributes = null; conn.exec = async (command, attributes) => { capturedCommand = command; capturedAttributes = JSON.stringify(attributes); return { next: () => {} }; }; searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT'] }).then(() => { test.equal(capturedCommand, 'UID SEARCH'); test.ok(capturedAttributes.includes('"RETURN"'), 'should include RETURN atom'); test.ok(capturedAttributes.includes('"COUNT"'), 'should include COUNT in return list'); test.done(); }).catch(err => test.done(err)); }; module.exports['ESEARCH: RETURN clause includes PARTIAL range atom'] = test => { const conn = makeConnection({ hasEsearch: true }); let capturedAttributes = null; conn.exec = async (command, attributes) => { capturedAttributes = JSON.stringify(attributes); return { next: () => {} }; }; searchCmd(conn, { seen: false }, { uid: true, returnOptions: [{ partial: '1:100' }] }).then(() => { test.ok(capturedAttributes.includes('"PARTIAL"'), 'should include PARTIAL atom'); test.ok(capturedAttributes.includes('"1:100"'), 'should include range string'); test.done(); }).catch(err => test.done(err)); }; module.exports['ESEARCH: no RETURN clause when server lacks ESEARCH capability'] = test => { const conn = makeConnection({ hasEsearch: false }); let capturedAttributes = null; conn.exec = async (command, attributes, handlers) => { capturedAttributes = JSON.stringify(attributes); // Simulate plain SEARCH response const searchHandler = handlers && handlers.untagged && handlers.untagged.SEARCH; if (searchHandler) { await searchHandler({ attributes: [{ value: '1' }, { value: '2' }, { value: '3' }] }); } return { next: () => {} }; }; searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT', 'ALL'] }).then(result => { test.ok(!capturedAttributes.includes('"RETURN"'), 'should NOT include RETURN when no ESEARCH'); test.ok(Array.isArray(result), 'should return number[] when ESEARCH unavailable'); test.deepEqual(result, [1, 2, 3]); test.done(); }).catch(err => test.done(err)); }; module.exports['ESEARCH: parseEsearchResponse skips non-ATOM tokens'] = test => { const attrs = [ [{ type: 'ATOM', value: 'stray' }], // non-ATOM (Array) token is skipped { type: 'ATOM', value: 'COUNT' }, { type: 'ATOM', value: '9' } ]; const result = parseEsearchResponse(attrs); test.equal(result.count, 9); test.done(); }; module.exports['ESEARCH: parseEsearchResponse skips trailing key with no value'] = test => { const attrs = [{ type: 'ATOM', value: 'COUNT' }]; // dangling key, no value const result = parseEsearchResponse(attrs); test.deepEqual(result, {}); test.done(); }; module.exports['ESEARCH: untagged handler parses ESEARCH response'] = test => { const conn = makeConnection({ hasEsearch: true }); conn.exec = async (command, attributes, handlers) => { const esearch = handlers && handlers.untagged && handlers.untagged.ESEARCH; // leading (TAG "A1") list + UID atom are stripped before parsing await esearch({ attributes: [ [ { type: 'ATOM', value: 'TAG' }, { type: 'STRING', value: 'A1' } ], { type: 'ATOM', value: 'UID' }, { type: 'ATOM', value: 'COUNT' }, { type: 'ATOM', value: '7' } ] }); // also exercise the empty-attributes guard await esearch({ attributes: null }); return { next: () => {} }; }; searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT'] }) .then(result => { test.equal(result.count, 7); test.done(); }) .catch(err => test.done(err)); }; module.exports['ESEARCH: command path returns false on exec error'] = test => { const conn = makeConnection({ hasEsearch: true }); conn.exec = async () => { let err = new Error('search failed'); err.responseStatus = 'NO'; throw err; }; searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT'] }) .then(result => { test.equal(result, false); test.done(); }) .catch(err => test.done(err)); }; module.exports['ESEARCH: parseEsearchResponse ignores unknown keywords'] = test => { // Dovecot with CONDSTORE may append MODSEQ to ESEARCH responses const attrs = [ { type: 'ATOM', value: 'COUNT' }, { type: 'ATOM', value: '5' }, { type: 'ATOM', value: 'MODSEQ' }, { type: 'ATOM', value: '12345' } ]; const result = parseEsearchResponse(attrs); test.equal(result.count, 5); test.equal(result.modseq, undefined, 'unknown keys should not appear in result'); test.done(); }; module.exports['ESEARCH: backward compat — no returnOptions returns number[]'] = test => { const conn = makeConnection({ hasEsearch: true }); conn.exec = async (command, attributes, handlers) => { const searchHandler = handlers && handlers.untagged && handlers.untagged.SEARCH; if (searchHandler) { await searchHandler({ attributes: [{ value: '10' }, { value: '20' }] }); } return { next: () => {} }; }; // No returnOptions — must return number[] even if server has ESEARCH searchCmd(conn, { seen: true }, { uid: true }).then(result => { test.ok(Array.isArray(result)); test.deepEqual(result, [10, 20]); test.done(); }).catch(err => test.done(err)); }; // ── imap-flow.js public API fallback test ───────────────────────────────── module.exports['imap-flow: search() derives ESearchResult when server has no ESEARCH'] = test => { const client = new ImapFlow({ host: 'imap.example.com', port: 993, auth: { user: 'test', pass: 'test' }, logger: false }); // Simulate a selected mailbox and no ESEARCH capability client.mailbox = { path: 'INBOX' }; client.state = client.states.SELECTED; client.capabilities = new Map(); // no ESEARCH // Stub run() to return a sorted number[] client.run = async () => [10, 20, 30, 40, 50]; client.search({ seen: false }, { uid: true, returnOptions: ['COUNT', 'MIN', 'MAX', 'ALL'] }).then(result => { test.equal(typeof result, 'object', 'should return object, not array'); test.ok(!Array.isArray(result), 'should not be an array'); test.equal(result.count, 5); test.equal(result.min, 10); test.equal(result.max, 50); // packMessageRange([10,20,30,40,50]) → "10,20,30,40,50" (non-contiguous) test.ok(typeof result.all === 'string' && result.all.length > 0, 'all should be non-empty compact string'); test.done(); }).catch(err => test.done(err)); }; module.exports['imap-flow: search() fallback with empty result set'] = test => { const client = new ImapFlow({ host: 'imap.example.com', port: 993, auth: { user: 'test', pass: 'test' }, logger: false }); client.mailbox = { path: 'INBOX' }; client.state = client.states.SELECTED; client.capabilities = new Map(); client.run = async () => []; client.search({}, { uid: true, returnOptions: ['COUNT', 'ALL'] }).then(result => { test.equal(result.count, 0); test.equal(result.all, undefined, 'all should be absent for empty result'); test.done(); }).catch(err => test.done(err)); };