imapflow
Version:
IMAP Client for Node
303 lines (278 loc) • 11.8 kB
JavaScript
'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));
};