import assert from 'node:assert/strict'; import { mkdtempSync } from 'node:fs'; import net from 'node:net'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { createDomain, createInboundMailbox, createInboundMessage, createUser, initDatabase, listInboundMessages } from '../src/db.js'; import { startMailboxAccessServers } from '../src/mail-access.js'; test('IMAP clients can log in and fetch mailbox messages', async () => { initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-test-')), 'mail-access-secret'); const { user, mailbox } = createMailboxFixture('imap.example', 'imap-user'); createInboundMessage(mailbox, { sender: 'alice@example.net', recipients: ['admin@imap.example'], subject: 'IMAP hello', messageId: '', rawMessage: [ 'From: Alice ', 'To: admin@imap.example', 'Subject: IMAP hello', 'Message-ID: ', '', 'Hello through IMAP.' ].join('\r\n'), textBody: 'Hello through IMAP.' }); const [server] = startMailboxAccessServers({ hostname: 'mail.imap.example', imapEnabled: true, imapListeners: [{ port: 0, protocol: 'imap' }], pop3Enabled: false, pop3Listeners: [], allowInsecureAuth: true }); await waitForListening(server); try { const port = server.address().port; const client = await connectClient(port); await client.readUntil(/\* OK .* IMAP ready\r\n/); assert.match(await client.command('A1 LOGIN "admin@imap.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/); const selected = await client.command('A2 SELECT INBOX', /A2 OK/); assert.match(selected, /\* 1 EXISTS/); const fetched = await client.command('A3 UID FETCH 1:* (UID FLAGS RFC822.SIZE BODY.PEEK[])', /A3 OK/); assert.match(fetched, /\* 1 FETCH/); assert.match(fetched, /UID 1/); assert.match(fetched, /Subject: IMAP hello/); assert.match(fetched, /Hello through IMAP\./); await client.command('A4 LOGOUT', /A4 OK/); client.close(); assert.equal(listInboundMessages(user.id).length, 1); } finally { await closeServer(server); } }); test('POP3 clients can retrieve and delete messages on quit', async () => { initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-pop3-test-')), 'mail-access-secret'); const { user, mailbox } = createMailboxFixture('pop3.example', 'pop3-user'); createInboundMessage(mailbox, { sender: 'bob@example.net', recipients: ['admin@pop3.example'], subject: 'POP3 hello', messageId: '', rawMessage: [ 'From: Bob ', 'To: admin@pop3.example', 'Subject: POP3 hello', 'Message-ID: ', '', 'Hello through POP3.' ].join('\r\n'), textBody: 'Hello through POP3.' }); const [server] = startMailboxAccessServers({ hostname: 'mail.pop3.example', imapEnabled: false, imapListeners: [], pop3Enabled: true, pop3Listeners: [{ port: 0, protocol: 'pop3' }], allowInsecureAuth: true }); await waitForListening(server); try { const client = await connectClient(server.address().port); await client.readUntil(/\+OK .* POP3 ready\r\n/); assert.match(await client.command('USER admin@pop3.example', /\+OK/), /User accepted/); assert.match(await client.command('PASS mailbox-pass-123', /\+OK/), /ready/); assert.match(await client.command('STAT', /\+OK \d+ \d+/), /\+OK 1 /); assert.match(await client.command('UIDL 1', /\+OK 1 mh-1/), /\+OK 1 mh-1/); const retrieved = await client.command('RETR 1', /\r\n\.\r\n/); assert.match(retrieved, /Subject: POP3 hello/); assert.match(retrieved, /Hello through POP3\./); assert.match(await client.command('DELE 1', /\+OK/), /deleted/); await client.command('QUIT', /\+OK Bye/); client.close(); assert.equal(listInboundMessages(user.id).length, 0); } finally { await closeServer(server); } }); function createMailboxFixture(domainName, username) { const user = createUser({ username, email: `${username}@example.com`, password: 'password123' }); createDomain(user.id, { domain: domainName, selector: 'mh', verificationToken: 'verify', dkimPublic: 'public', dkimPrivate: 'private', senderHost: `mail.${domainName}`, sendingIp: '192.0.2.30', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); const mailbox = createInboundMailbox(user.id, { address: `admin@${domainName}`, password: 'mailbox-pass-123' }); return { user, mailbox }; } function connectClient(port) { return new Promise((resolve, reject) => { const socket = net.createConnection({ host: '127.0.0.1', port }); socket.setEncoding('utf8'); socket.setTimeout(5000); let buffer = ''; const waiters = []; socket.on('data', (chunk) => { buffer += chunk; for (const waiter of [...waiters]) { if (waiter.pattern.test(buffer)) { waiters.splice(waiters.indexOf(waiter), 1); const output = buffer; buffer = ''; waiter.resolve(output); } } }); socket.once('connect', () => resolve({ command(command, pattern) { socket.write(`${command}\r\n`); return this.readUntil(pattern); }, readUntil(pattern) { if (pattern.test(buffer)) { const output = buffer; buffer = ''; return Promise.resolve(output); } return new Promise((waitResolve, waitReject) => { waiters.push({ pattern, resolve: waitResolve, reject: waitReject }); }); }, close() { socket.destroy(); } })); socket.once('error', reject); socket.once('timeout', () => reject(new Error('Mail access client timed out'))); }); } function waitForListening(server) { if (server.listening) return Promise.resolve(); return new Promise((resolve) => server.once('listening', resolve)); } function closeServer(server) { return new Promise((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()); }); }