Files
hub/tools/mail-imap-test.js
T

132 lines
17 KiB
JavaScript

#!/usr/bin/env node
// Gmail and iCloud mail (IMAP + SMTP with an app password): adding accounts, reading, flags, moving, sending, replies, privacy.
// Uses a fake IMAP server (hoodiecrow-imap) and a fake SMTP server (smtp-server) on localhost. Run: node tools/mail-imap-test.js
const { spawn } = require('child_process');
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const hoodiecrow = require('hoodiecrow-imap'), { SMTPServer } = require('smtp-server'), { simpleParser } = require('mailparser'), { ImapFlow } = require('imapflow');
const MailComposer = require('nodemailer/lib/mail-composer');
const root = path.join(__dirname, '..');
let failed = 0;
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
const wait = ms => new Promise(r => setTimeout(r, ms));
const SECRET = 'imap-test-secret-'.repeat(3), PASS = 'testpass', ME = 'testuser@example.com', USER = ME;
const free = () => 21000 + Math.floor(Math.random() * 20000);
(async () => {
// ---- messages in the fake mailbox
const build = async o => (await new MailComposer(o).compile().build()).toString();
const m1 = await build({ from: 'Ann Lee <ann@example.com>', to: ME, cc: 'bob@example.com', subject: 'Hello there', text: 'Hi there\nsecond line', date: new Date('2026-10-22T10:00:00Z'), messageId: '<hello1@example.com>' });
const m2 = await build({ from: 'Bank <no-reply@bank.example>', to: ME, subject: 'Statement', text: 'see attached', html: '<p>Your statement <img src="cid:logo1"></p><script>alert(1)</script>', date: new Date('2026-10-21T10:00:00Z'), messageId: '<stmt@bank.example>',
attachments: [{ filename: 'logo.png', content: Buffer.from('PNG!'), contentType: 'image/png', cid: 'logo1' }, { filename: 'report.pdf', content: Buffer.from('%PDF-1'), contentType: 'application/pdf' }] });
const m3 = await build({ from: 'Old Friend <old@example.com>', to: ME, subject: 'Old news', text: 'long ago', date: new Date('2026-10-01T10:00:00Z'), messageId: '<old@example.com>' });
const imapPort = free(), smtpPort = free() + 1;
const imapSrv = hoodiecrow({ users: { [ME]: { password: PASS }, 'me@icloud.com': { password: PASS } }, plugins: ['SPECIAL-USE', 'MOVE', 'UIDPLUS'], storage: {
INBOX: { messages: [{ raw: m1, flags: [] }, { raw: m2, flags: ['\\Seen'] }, { raw: m3, flags: [] }] },
'': { separator: '/', folders: { Sent: { 'special-use': '\\Sent' }, Trash: { 'special-use': '\\Trash' }, Receipts: {}, '[Gmail]': { flags: ['\\Noselect'], folders: { Spam: { 'special-use': '\\Junk' }, 'All Mail': { 'special-use': '\\All' } } } } } } });
await new Promise(r => imapSrv.listen(imapPort, r));
const sent = [];
const smtp = new SMTPServer({ authOptional: false, allowInsecureAuth: true, disabledCommands: ['STARTTLS'], authMethods: ['PLAIN', 'LOGIN'],
onAuth(a, s, cb) { [ME, 'me@icloud.com'].includes(a.username) && a.password === PASS ? cb(null, { user: a.username }) : cb(new Error('Invalid credentials')); },
onData(stream, session, cb) { const c = []; stream.on('data', d => c.push(d)); stream.on('end', async () => { sent.push({ from: session.envelope.mailFrom.address, to: session.envelope.rcptTo.map(r => r.address), parsed: await simpleParser(Buffer.concat(c)) }); cb(); }); } });
await new Promise(r => smtp.listen(smtpPort, '127.0.0.1', r));
const peek = async (box) => { const c = new ImapFlow({ host: '127.0.0.1', port: imapPort, secure: false, doSTARTTLS: false, auth: { user: USER, pass: PASS }, logger: false }); await c.connect(); try { return (await c.status(box, { messages: true, unseen: true })); } finally { await c.logout(); } };
// ---- the hub, with Nate (admin) and Ann (a mail user with no accounts)
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-imap-')), port = free() + 2, base = `http://localhost:${port}`;
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [
{ id: 'unate', name: 'Nate', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 },
{ id: 'uann', name: 'Ann', role: 'user', tabs: ['mail'], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 }] }));
const exp = Date.now() + 864e5, ck = uid => `hub_session=${uid}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${uid}.1.${exp}`).digest('base64url')}`;
const env = { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, MAIL_TEST_SERVERS: JSON.stringify({ imap: { host: '127.0.0.1', port: imapPort, secure: false }, smtp: { host: '127.0.0.1', port: smtpPort } }) }; delete env.MS_CLIENT_ID;
const child = spawn(process.execPath, ['server.js'], { cwd: root, env }); let log = ''; child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d);
for (let i = 0; i < 60; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } }
const call = async (uid, p, body, method) => { const r = await fetch(base + p, { method: method || (body ? 'POST' : 'GET'), headers: { cookie: ck(uid), 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); const t = await r.text(); let j; try { j = JSON.parse(t); } catch { j = t; } return { status: r.status, json: j, headers: r.headers, text: t }; };
const A = (p, b, m) => call('unate', p, b, m);
// ================= adding accounts =================
let r = await A('/api/mail/status'); ok('before adding anything: not connected, and the hub offers Gmail and iCloud', r.json.connected === false && r.json.providers.gmail && r.json.providers.icloud, r.json);
r = await A('/api/mail/accounts', { type: 'gmail', address: ME, password: 'wrongpassword1' }); ok('a wrong app password is refused with advice for Gmail', r.status === 401 && /app password/i.test(r.json.error) && /Google/.test(r.json.error), r);
r = await A('/api/mail/accounts', { type: 'icloud', address: ME, password: 'wrongpassword1' }); ok('and with advice for iCloud', r.status === 401 && /Apple/.test(r.json.error) && /app-specific/i.test(r.json.error), r);
r = await A('/api/mail/accounts', { type: 'gmail', address: 'nonsense', password: PASS }); ok('a malformed address is refused', r.status === 400, r.json);
r = await A('/api/mail/accounts', { type: 'yahoo', address: ME, password: PASS }); ok('an unknown provider is refused', r.status === 400, r.json);
r = await A('/api/mail/accounts', { type: 'gmail', address: ME, password: PASS }); const gm = r.json.account;
ok('the right app password connects a Gmail account', r.status === 200 && gm && gm.type === 'gmail' && gm.address === ME, r);
ok('the password is never sent back', !r.text.includes(PASS) && !(await A('/api/mail/status')).text.includes(PASS));
ok('and is stored encrypted', !fs.readFileSync(path.join(dir, 'users', 'unate', 'mail-accounts.json'), 'utf8').includes(PASS));
r = await A('/api/mail/status'); ok('status lists the account with its unread count (2 unread in the inbox)', r.json.connected && r.json.accounts.length === 1 && r.json.accounts[0].unread === 2 && r.json.unread === 2, r.json);
r = await A('/api/mail/status?light=1'); ok('the light status lists the accounts without asking the mail servers (what lets the Mail tab draw at once)', r.json.connected && r.json.accounts.length === 1 && !r.json.accounts[0].error, r.json);
const t0 = Date.now(); r = await A('/api/mail/status'); const t1 = Date.now(); r = await A('/api/mail/status'); ok('the full status is answered from a short-lived cache the second time (counts are refreshed at most every 30 s)', Date.now() - t1 <= Math.max(50, t1 - t0), [t1 - t0, Date.now() - t1]);
// ================= reading =================
r = await A('/api/mail/folders?acct=' + gm.id); const names = r.json.map(f => f.name);
ok('folders: Inbox, Sent, Archive (Gmail "All Mail"), Junk, Deleted, then your own; the [Gmail] container is hidden', names.join() === 'Inbox,Sent,Archive,Junk,Deleted,Receipts' && r.json[0].unread === 2 && r.json[0].id.startsWith(gm.id + '~'), r.json);
r = await A('/api/mail/messages?folder=' + gm.id + '~inbox'); const items = r.json.items;
ok('the inbox lists messages newest first with sender, read state and attachment flag', items.length === 3 && items[0].subject === 'Hello there' && items[0].from.address === 'ann@example.com' && items[0].read === false && items[1].subject === 'Statement' && items[1].read === true && items[1].attach === true && items[0].attach === false, items);
ok('ids carry the account', items.every(m => m.id.startsWith(gm.id + '~')), items.map(m => m.id));
r = await A('/api/mail/messages?folder=inbox'); ok('a folder with no account in it means the first account (older links)', r.json.items.length === 3, r.json);
r = await A('/api/mail/messages?folder=' + gm.id + '~inbox&q=statement'); ok('search finds a message', r.json.items.length === 1 && r.json.items[0].subject === 'Statement', r.json);
r = await A('/api/mail/messages?folder=' + gm.id + '~p-' + Buffer.from('Nope').toString('base64url')); ok('a folder that does not exist is a 404, not a crash', r.status === 404, r);
const helloId = items[0].id, stmtId = items[1].id, oldId = items[2].id;
r = await A('/api/mail/message?id=' + encodeURIComponent(helloId)); const hm = r.json;
ok('a message opens with sender, recipients (to and cc), date and text', hm.subject === 'Hello there' && hm.to[0].address === ME && hm.cc[0].address === 'bob@example.com' && /Hi there/.test(hm.html) && hm.attachments.length === 0, hm);
r = await A('/api/mail/message?id=' + encodeURIComponent(stmtId)); const sm = r.json;
ok('an inline picture is put in the page (cid -> data), and only real attachments are listed', /data:image\/png;base64,/.test(sm.html) && !/cid:logo1/.test(sm.html) && sm.attachments.length === 1 && sm.attachments[0].name === 'report.pdf', sm);
r = await A('/api/mail/attachment?id=' + encodeURIComponent(stmtId) + '&aid=' + sm.attachments[0].id); ok('an attachment downloads as a file', r.status === 200 && r.text === '%PDF-1' && /filename="report\.pdf"/.test(r.headers.get('content-disposition')), [r.status, r.text]);
r = await A('/api/mail/message?id=' + encodeURIComponent('abc~' + helloId.split('~')[1])); ok('an unknown account in an id is a 404', r.status === 404, r.status);
r = await A('/api/mail/message?id=' + encodeURIComponent(gm.id + '~garbage')); ok('a damaged id is a 400', r.status === 400, r.status);
// ================= flags, moving, deleting =================
await A('/api/mail/update', { id: helloId, read: true }); r = await A('/api/mail/messages?folder=' + gm.id + '~inbox'); ok('mark as read', r.json.items[0].read === true, r.json.items[0]);
await A('/api/mail/update', { id: helloId, read: false, flagged: true }); r = await A('/api/mail/messages?folder=' + gm.id + '~inbox'); ok('mark unread and flag', r.json.items[0].read === false && r.json.items[0].flagged === true, r.json.items[0]);
r = await A('/api/mail/update', { id: helloId }); ok('an update with nothing to change is refused', r.status === 400, r.status);
const archBefore = (await peek('[Gmail]/All Mail')).messages;
r = await A('/api/mail/move', { id: oldId, to: 'archive' }); ok('archive moves a message to All Mail', r.status === 200 && (await peek('[Gmail]/All Mail')).messages === archBefore + 1 && (await peek('INBOX')).messages === 2, r);
const trashBefore = (await peek('Trash')).messages;
r = await A('/api/mail/delete', { id: stmtId }); ok('delete moves a message to Trash', r.status === 200 && (await peek('Trash')).messages === trashBefore + 1 && (await peek('INBOX')).messages === 1, r);
r = await A('/api/mail/messages?folder=' + gm.id + '~deleteditems'); const trashed = r.json.items[0];
r = await A('/api/mail/delete', { id: trashed.id, permanent: true }); ok('and from Trash it is removed for good', r.status === 200 && (await peek('Trash')).messages === trashBefore, r);
// ================= sending =================
sent.length = 0; const sentBefore = (await peek('Sent')).messages;
r = await A('/api/mail/send', { acct: gm.id, to: 'bob@example.com, Carol <carol@example.com>', subject: 'Plans', body: 'Line 1\n<b>not bold</b>', attachments: [{ name: 'a.txt', type: 'text/plain', data: Buffer.from('hello').toString('base64') }] });
const s1 = sent[0];
ok('send goes out over SMTP from the account (several recipients, text turned into safe HTML, attachment kept)', r.status === 200 && s1 && s1.from === ME && s1.to.join() === 'bob@example.com,carol@example.com' && s1.parsed.subject === 'Plans' && /not bold/.test(s1.parsed.text) && /&lt;b&gt;not bold/.test(s1.parsed.html) && s1.parsed.attachments[0].filename === 'a.txt', [r, s1 && s1.to]);
ok('Gmail keeps its own copy, so none is added to Sent here', (await peek('Sent')).messages === sentBefore, (await peek('Sent')).messages);
r = await A('/api/mail/send', { acct: gm.id, to: 'nobody', subject: 'x', body: 'y' }); ok('a bad address is refused with its name', r.status === 400 && /nobody/.test(r.json.error), r.json);
r = await A('/api/mail/send', { acct: gm.id, to: '', subject: 'x', body: 'y' }); ok('no recipient is refused', r.status === 400, r.json);
r = await A('/api/mail/send', { acct: gm.id, to: 'a@b.co', subject: 'x', body: 'y', attachments: [{ name: 'big', type: 'x/y', data: Buffer.alloc(3.5 * 1024 * 1024).toString('base64') }] }); ok('attachments over about 3 MB are refused', r.status === 400 && /3 MB/.test(r.json.error), r.json);
// ================= replies =================
sent.length = 0; r = await A('/api/mail/reply', { id: helloId, mode: 'reply', comment: 'Thanks Ann' }); const rp = sent[0];
ok('reply goes to the sender, keeps the thread headers and quotes the original', r.status === 200 && rp.to.join() === 'ann@example.com' && rp.parsed.subject === 'Re: Hello there' && rp.parsed.inReplyTo === '<hello1@example.com>' && /Thanks Ann/.test(rp.parsed.text) && /> Hi there/.test(rp.parsed.text), [r, rp && rp.parsed.inReplyTo, rp && rp.parsed.text]);
sent.length = 0; r = await A('/api/mail/reply', { id: helloId, mode: 'replyAll', comment: 'All' }); const ra = sent[0];
ok('reply all adds the others on the message but not yourself', r.status === 200 && ra.to.includes('ann@example.com') && ra.to.includes('bob@example.com') && !ra.to.includes(ME), ra && ra.to);
sent.length = 0; r = await A('/api/mail/reply', { id: helloId, mode: 'forward', comment: 'FYI', to: 'x@y.org' }); const rf = sent[0];
ok('forward needs a recipient and sends the original', r.status === 200 && rf.to.join() === 'x@y.org' && rf.parsed.subject === 'Fwd: Hello there' && /Forwarded message/.test(rf.parsed.text) && /Hi there/.test(rf.parsed.text), rf && rf.parsed.text);
r = await A('/api/mail/reply', { id: helloId, mode: 'forward', comment: 'FYI' }); ok('forward with no recipient is refused', r.status === 400, r.json);
r = await A('/api/mail/reply', { id: helloId, mode: 'bogus' }); ok('an unknown action is refused', r.status === 400, r.json);
// ================= a second account (iCloud), several accounts =================
r = await A('/api/mail/accounts', { type: 'icloud', address: 'me@icloud.com', password: PASS }); ok('an iCloud account connects the same way', r.status === 200 && r.json.account.type === 'icloud', r);
const ic = r.json.account;
r = await A('/api/mail/status'); ok('both accounts are listed and the unread counts add up', r.json.accounts.length === 2 && r.json.unread === r.json.accounts.reduce((n, x) => n + x.unread, 0), r.json);
const sentB = (await peek('Sent')).messages; sent.length = 0;
r = await A('/api/mail/send', { acct: ic.id, to: 'bob@example.com', subject: 'From iCloud', body: 'hi' });
ok('iCloud sends as that address', r.status === 200 && sent[0].from === 'me@icloud.com', [r, sent[0] && sent[0].from]);
ok('and iCloud does not keep a copy itself, so one is added to Sent', (await peek('Sent')).messages === sentB + 1, [(await peek('Sent')).messages, sentB]);
// ================= privacy and removal =================
r = await call('uann', '/api/mail/status'); ok('another person sees no accounts', r.json.connected === false && r.json.accounts.length === 0, r.json);
r = await call('uann', '/api/mail/message?id=' + encodeURIComponent(helloId)); ok("and cannot open Nate's messages with his ids", r.status === 409 || r.status === 404, r.status);
r = await call('uann', '/api/mail/send', { acct: gm.id, to: 'a@b.co', subject: 'x', body: 'y' }); ok("or send from Nate's account", r.status === 404 || r.status === 409, r.status);
r = await A('/api/mail/disconnect', { acct: ic.id }); r = await A('/api/mail/status'); ok('disconnecting one account leaves the other', r.json.accounts.length === 1 && r.json.accounts[0].id === gm.id, r.json);
r = await A('/api/mail/disconnect', { acct: 'nope' }); ok('disconnecting an unknown account is a 404', r.status === 404, r.status);
r = await A('/api/mail/disconnect', {}); r = await A('/api/mail/status'); ok('disconnect with no account removes them all', r.json.connected === false, r.json);
r = await A('/api/audit'); const evs = (r.json.events || []).map(e => e.event); ok('connecting and sending are in the activity log (no passwords, no text)', evs.includes('mail connected') && evs.includes('mail sent') && !JSON.stringify(r.json).includes(PASS) && !JSON.stringify(r.json).includes('not bold'), evs);
ok('the server is still running', child.exitCode === null, log.slice(-300));
child.kill(); imapSrv.close(); smtp.close(); fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll IMAP mail checks passed'); process.exit(failed ? 1 : 0);
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });