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, createInboundFolder, createInboundMailbox, createInboundMessage, createImportedInboundMessage, createUser, getInboundMessage, inboundFolderExists, initDatabase, listInboundMessages } from '../src/db.js'; import { startMailboxAccessServers } from '../src/mail-access.js'; test('IMAP SELECT keeps message bodies lazy and FETCH hydrates one message', async () => { const database = initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-test-')), 'mail-access-secret'); const { user, mailbox } = createMailboxFixture('imap.example', 'imap-user'); const storedMessage = 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/); database .prepare('UPDATE inbound_messages SET raw_message_bytes = ? WHERE id = ?') .run(Buffer.from(storedMessage.rawMessage.replace('Hello through IMAP.', 'Hallo through IMAP.'), 'utf8'), storedMessage.id); 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, /Hallo through IMAP\./); assert.doesNotMatch(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('IMAP exposes imported Maildir flags and Dovecot keywords', async () => { initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-flags-test-')), 'mail-access-secret'); const { mailbox } = createMailboxFixture('flags.example', 'flags-user'); createImportedInboundMessage(mailbox, { importSource: 'vesta:flags', sourceKey: 'message-1', sender: 'sender@example.net', recipients: ['admin@flags.example'], subject: 'Imported flags', messageId: '', rawMessageBytes: Buffer.from('From: sender@example.net\r\nTo: admin@flags.example\r\nSubject: Imported flags\r\n\r\nBody', 'utf8'), flags: ['\\Answered', '\\Flagged', '\\Draft', '\\Seen'], keywords: ['$Label1', 'custom-keyword'], receivedAt: '2024-01-02T03:04:05.000Z' }); const [server] = startMailboxAccessServers({ hostname: 'mail.flags.example', imapEnabled: true, imapListeners: [{ port: 0, protocol: 'imap' }], pop3Enabled: false, pop3Listeners: [], allowInsecureAuth: true }); await waitForListening(server); let client; try { client = await connectClient(server.address().port); await client.readUntil(/\* OK .* IMAP ready\r\n/); await client.command('A1 LOGIN "admin@flags.example" "mailbox-pass-123"', /A1 OK/); const selected = await client.command('A2 SELECT INBOX', /A2 OK/); assert.match(selected, /\* FLAGS \([^\r\n]*\\Answered/); assert.match(selected, /\* FLAGS \([^\r\n]*\$Label1/); assert.match(selected, /\* FLAGS \([^\r\n]*custom-keyword/); const fetched = await client.command('A3 UID FETCH 1:* (UID FLAGS)', /A3 OK/); for (const flag of ['\\Answered', '\\Flagged', '\\Draft', '\\Seen', '$Label1', 'custom-keyword']) { assert.ok(fetched.includes(flag)); } await client.command('A4 LOGOUT', /A4 OK/); } finally { client?.close(); await closeServer(server); } }); test('IMAP exposes MIME body structures and individual parts for Roundcube', async () => { initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-mime-test-')), 'mail-access-secret'); const { mailbox } = createMailboxFixture('mime.example', 'mime-user'); createInboundMessage(mailbox, { sender: 'alice@example.net', recipients: ['admin@mime.example'], subject: 'MIME message', messageId: '', rawMessage: [ 'From: Alice ', 'To: admin@mime.example', 'Subject: MIME message', 'MIME-Version: 1.0', 'Content-Type: multipart/alternative; boundary="mailhub-boundary"', '', '--mailhub-boundary', 'Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: quoted-printable', '', 'Plain message body.', '--mailhub-boundary', 'Content-Type: text/html; charset=UTF-8', '', '

HTML message body.

', '--mailhub-boundary--', '' ].join('\r\n'), textBody: 'Plain message body.', htmlBody: '

HTML message body.

' }); const [server] = startMailboxAccessServers({ hostname: 'mail.mime.example', imapEnabled: true, imapListeners: [{ port: 0, protocol: 'imap' }], pop3Enabled: false, pop3Listeners: [], allowInsecureAuth: true }); await waitForListening(server); let client; try { client = await connectClient(server.address().port); await client.readUntil(/\* OK .* IMAP ready\r\n/); assert.match(await client.command('A1 LOGIN "admin@mime.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/); await client.command('A2 SELECT INBOX', /A2 OK/); const structure = await client.command('A3 UID FETCH 1 (UID BODYSTRUCTURE)', /A3 OK/); assert.match(structure, /BODYSTRUCTURE \(\("TEXT" "PLAIN" \("CHARSET" "UTF-8"\).*\) \("TEXT" "HTML" \("CHARSET" "UTF-8"\).*\) "ALTERNATIVE" \("BOUNDARY" "mailhub-boundary"\)\)/); const textPart = await client.command('A4 UID FETCH 1 (BODY.PEEK[1])', /A4 OK/); assert.match(textPart, /BODY\[1\] \{\d+\}\r\nPlain message body\./); assert.doesNotMatch(textPart, /Content-Type: text\/plain/); const htmlPart = await client.command('A5 UID FETCH 1 (BODY.PEEK[2])', /A5 OK/); assert.match(htmlPart, /BODY\[2\] \{\d+\}\r\n

HTML message body\.<\/p>/); const mimeHeaders = await client.command('A6 UID FETCH 1 (BODY.PEEK[1.MIME])', /A6 OK/); assert.match(mimeHeaders, /BODY\[1\.MIME\] \{\d+\}\r\nContent-Type: text\/plain; charset=UTF-8/); await client.command('A7 LOGOUT', /A7 OK/); client.close(); } finally { client?.close(); await closeServer(server); } }); test('IMAP exposes standard folders expected by mainstream clients', async () => { initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-folders-test-')), 'mail-access-secret'); createMailboxFixture('folders.example', 'folders-user'); const [server] = startMailboxAccessServers({ hostname: 'mail.folders.example', imapEnabled: true, imapListeners: [{ port: 0, protocol: 'imap' }], pop3Enabled: false, pop3Listeners: [], allowInsecureAuth: true }); await waitForListening(server); let client; try { client = await connectClient(server.address().port); await client.readUntil(/\* OK .* IMAP ready\r\n/); assert.match(await client.command('A1 LOGIN "admin@folders.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/); const listed = await client.command('A2 LIST "" "*"', /A2 OK/); assert.match(listed, /\* LIST .* "INBOX"/); assert.match(listed, /\* LIST .*\\Sent.* "Sent"/); assert.match(listed, /\* LIST .*\\Drafts.* "Drafts"/); assert.match(listed, /\* LIST .*\\Trash.* "Trash"/); assert.match(listed, /\* LIST .*\\Junk.* "Junk"/); assert.match(listed, /\* LIST .*\\Archive.* "Archive"/); const selected = await client.command('A3 SELECT Sent', /A3 OK/); assert.match(selected, /\* 0 EXISTS/); await client.command('A4 LOGOUT', /A4 OK/); client.close(); } finally { client?.close(); await closeServer(server); } }); test('IMAP uses Modified UTF-7 on the wire while storing Unicode folder names', async () => { initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-utf7-test-')), 'mail-access-secret'); const { user, mailbox } = createMailboxFixture('utf7.example', 'utf7-user'); createInboundFolder(mailbox, '中文 & 项目'); const [server] = startMailboxAccessServers({ hostname: 'mail.utf7.example', imapEnabled: true, imapListeners: [{ port: 0, protocol: 'imap' }], pop3Enabled: false, pop3Listeners: [], allowInsecureAuth: true }); await waitForListening(server); let client; try { client = await connectClient(server.address().port); await client.readUntil(/\* OK .* IMAP ready\r\n/); await client.command('A1 LOGIN "admin@utf7.example" "mailbox-pass-123"', /A1 OK/); const listed = await client.command('A2 LIST "" "*"', /A2 OK/); assert.match(listed, /"&Ti1lhw- &- &mHl27g-"/); assert.doesNotMatch(listed, /中文|项目/); const subscribed = await client.command('A2L LSUB "" "*"', /A2L OK/); assert.match(subscribed, /"&Ti1lhw- &- &mHl27g-"/); const selected = await client.command('A3 SELECT "&Ti1lhw- &- &mHl27g-"', /A3 OK/); assert.match(selected, /\* 0 EXISTS/); const status = await client.command('A4 STATUS "&Ti1lhw- &- &mHl27g-" \(MESSAGES UNSEEN\)', /A4 OK/); assert.match(status, /\* STATUS "&Ti1lhw- &- &mHl27g-" \(MESSAGES 0 UNSEEN 0/); await client.command('A5 CREATE "&ZeVnLIqe-"', /A5 OK/); assert.equal(inboundFolderExists(mailbox, '日本語'), true); const rawMessage = [ 'From: Bob ', 'To: admin@utf7.example', 'Subject: UTF-7 folder append', '', 'Imported into a Unicode folder.' ].join('\r\n'); await client.append( `A6 APPEND "&ZeVnLIqe-" {${Buffer.byteLength(rawMessage, 'utf8')}}`, rawMessage, /A6 OK/ ); assert.equal(listInboundMessages(user.id, { folder: '日本語' }).length, 1); await client.command('A7 LOGOUT', /A7 OK/); client.close(); } finally { client?.close(); await closeServer(server); } }); test('IMAP APPEND stores sent messages in the Sent folder', async () => { initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-append-test-')), 'mail-access-secret'); const { user } = createMailboxFixture('append.example', 'append-user'); const [server] = startMailboxAccessServers({ hostname: 'mail.append.example', imapEnabled: true, imapListeners: [{ port: 0, protocol: 'imap' }], pop3Enabled: false, pop3Listeners: [], allowInsecureAuth: true }); await waitForListening(server); let client; try { const sentMessage = [ 'From: Admin ', 'To: Bob ', 'Subject: =?UTF-8?Q?=E6=A0=B8=E4=BA=91?=', ' =?UTF-8?Q?=E8=AE=A1=E7=AE=97?=', 'Message-ID: ', 'MIME-Version: 1.0', 'Content-Type: multipart/alternative; boundary="sent-boundary"', '', '--sent-boundary', 'Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: base64', '', Buffer.from('工单正文', 'utf8').toString('base64'), '--sent-boundary', 'Content-Type: text/html; charset=UTF-8', 'Content-Transfer-Encoding: quoted-printable', '', '

Sent HTML body.

', '--sent-boundary--', '' ].join('\r\n'); client = await connectClient(server.address().port); await client.readUntil(/\* OK .* IMAP ready\r\n/); assert.match(await client.command('A1 LOGIN "admin@append.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/); await client.append(`A2 APPEND Sent (\\Seen) {${Buffer.byteLength(sentMessage, 'utf8')}}`, sentMessage, /A2 OK/); const selectedSent = await client.command('A3 SELECT Sent', /A3 OK/); assert.match(selectedSent, /\* 1 EXISTS/); const fetchedSent = await client.command('A4 UID FETCH 1:* (UID FLAGS BODY.PEEK[])', /A4 OK/); assert.match(fetchedSent, /FLAGS \(\\Seen\)/); assert.match(fetchedSent, /Subject: =\?UTF-8\?Q\?/); assert.match(fetchedSent, /--sent-boundary/); const [storedSummary] = listInboundMessages(user.id, { folder: 'Sent' }); const storedMessage = getInboundMessage(user.id, storedSummary.id); assert.equal(storedMessage.subject, '核云计算'); assert.equal(storedMessage.textBody, '工单正文'); assert.match(storedMessage.htmlBody, /Sent HTML body/); assert.equal(storedMessage.preview, '工单正文'); assert.match(storedMessage.rawMessage, /--sent-boundary/); const latin1Message = Buffer.concat([ Buffer.from([ 'From: Admin ', 'To: Bob ', 'Subject: Latin1 copy', 'Content-Type: text/plain; charset=ISO-8859-1', 'Content-Transfer-Encoding: 8bit', '', 'caf' ].join('\r\n'), 'ascii'), Buffer.from([0xe9]) ]); await client.append(`A5 APPEND Sent {${latin1Message.length}}`, latin1Message, /A5 OK/); const latin1Summary = listInboundMessages(user.id, { folder: 'Sent' }) .find((message) => message.subject === 'Latin1 copy'); assert.equal(getInboundMessage(user.id, latin1Summary.id).textBody, 'café'); await client.command('A6 SELECT Sent', /A6 OK/); const latin1Fetch = await client.commandBytes('A7 UID FETCH 1:* (UID BODY.PEEK[])', /A7 OK/); assert.equal(latin1Fetch.includes(latin1Message), true); const selectedInbox = await client.command('A8 SELECT INBOX', /A8 OK/); assert.match(selectedInbox, /\* 0 EXISTS/); await client.command('A9 LOGOUT', /A9 OK/); client.close(); } finally { client?.close(); await closeServer(server); } }); test('POP3 clients can retrieve and delete messages on quit', async () => { const database = initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-pop3-test-')), 'mail-access-secret'); const { user, mailbox } = createMailboxFixture('pop3.example', 'pop3-user'); const firstRawMessage = [ 'From: Bob ', 'To: admin@pop3.example', 'Subject: POP3 hello', 'Message-ID: ', '', 'Hello through POP3.' ].join('\r\n'); const firstMessage = createInboundMessage(mailbox, { sender: 'bob@example.net', recipients: ['admin@pop3.example'], subject: 'POP3 hello', messageId: '', rawMessage: firstRawMessage, textBody: 'Hello through POP3.' }); const latin1RawMessage = Buffer.concat([ Buffer.from([ 'From: Alice ', 'To: admin@pop3.example', 'Subject: Latin1 POP3', 'Content-Type: text/plain; charset=ISO-8859-1', 'Content-Transfer-Encoding: 8bit', '', 'caf' ].join('\n'), 'ascii'), Buffer.from([0xe9]) ]); createInboundMessage(mailbox, { sender: 'alice@example.net', recipients: ['admin@pop3.example'], subject: 'Latin1 POP3', rawMessage: latin1RawMessage.toString('latin1'), rawMessageBytes: latin1RawMessage, textBody: 'café' }); const firstPop3Message = Buffer.from(`${firstRawMessage}\r\n`, 'utf8'); const latin1Pop3Message = Buffer.concat([ Buffer.from(latin1RawMessage.toString('latin1').replace(/\n/g, '\r\n'), 'latin1'), Buffer.from('\r\n') ]); const totalOctets = firstPop3Message.length + latin1Pop3Message.length; 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+/), new RegExp(`\\+OK 2 ${totalOctets}`)); const listed = await client.command('LIST', /\r\n\.\r\n/); assert.match(listed, new RegExp(`1 ${firstPop3Message.length}\\r\\n`)); assert.match(listed, new RegExp(`2 ${latin1Pop3Message.length}\\r\\n`)); assert.match(await client.command('UIDL 1', /\+OK 1 mh-1/), /\+OK 1 mh-1/); database .prepare('UPDATE inbound_messages SET raw_message_bytes = ? WHERE id = ?') .run(Buffer.from(firstRawMessage.replace('Hello through POP3.', 'Hallo through POP3.'), 'utf8'), firstMessage.id); const retrieved = await client.command('RETR 1', /\r\n\.\r\n/); assert.match(retrieved, /Subject: POP3 hello/); assert.match(retrieved, /Hallo through POP3\./); assert.doesNotMatch(retrieved, /Hello through POP3\./); const latin1Retrieved = await client.commandBytes('RETR 2', /\r\n\.\r\n/); assert.deepEqual(latin1Retrieved, Buffer.concat([ Buffer.from(`+OK ${latin1Pop3Message.length} octets\r\n`), latin1Pop3Message, Buffer.from('.\r\n') ])); assert.match(await client.command('DELE 1', /\+OK/), /deleted/); assert.match(await client.command('DELE 2', /\+OK/), /deleted/); await client.command('QUIT', /\+OK Bye/); client.close(); assert.equal(listInboundMessages(user.id).length, 0); } finally { await closeServer(server); } }); test('POP3 AUTH PLAIN requires TLS when insecure authentication is disabled', async () => { const [server] = startMailboxAccessServers({ hostname: 'mail.secure-pop3.example', imapEnabled: false, imapListeners: [], pop3Enabled: true, pop3Listeners: [{ port: 0, protocol: 'pop3' }], allowInsecureAuth: false }); await waitForListening(server); let client; try { client = await connectClient(server.address().port); await client.readUntil(/\+OK .* POP3 ready\r\n/); const credentials = Buffer.from('\u0000user@example.com\u0000password').toString('base64'); assert.equal( await client.command(`AUTH PLAIN ${credentials}`, /\+OK|\-ERR/), '-ERR Encryption required for authentication\r\n' ); } finally { client?.close(); 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.setTimeout(5000); let buffer = ''; let rawBuffer = Buffer.alloc(0); const waiters = []; socket.on('data', (chunk) => { const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); rawBuffer = Buffer.concat([rawBuffer, bytes]); buffer += bytes.toString('utf8'); for (const waiter of [...waiters]) { if (waiter.pattern.test(buffer)) { waiters.splice(waiters.indexOf(waiter), 1); const output = buffer; const rawOutput = rawBuffer; buffer = ''; rawBuffer = Buffer.alloc(0); waiter.resolve(waiter.raw ? rawOutput : output); } } }); socket.once('connect', () => resolve({ command(command, pattern) { socket.write(`${command}\r\n`); return this.readUntil(pattern); }, commandBytes(command, pattern) { socket.write(`${command}\r\n`); return this.readUntil(pattern, true); }, async append(command, literal, pattern) { socket.write(`${command}\r\n`); await this.readUntil(/^\+ /m); socket.write(literal); socket.write('\r\n'); return this.readUntil(pattern); }, readUntil(pattern, raw = false) { if (pattern.test(buffer)) { const output = buffer; const rawOutput = rawBuffer; buffer = ''; rawBuffer = Buffer.alloc(0); return Promise.resolve(raw ? rawOutput : output); } return new Promise((waitResolve, waitReject) => { const waiter = { pattern, raw, resolve(output) { clearTimeout(waiter.timer); waitResolve(output); }, reject(error) { clearTimeout(waiter.timer); waitReject(error); }, timer: null }; waiter.timer = setTimeout(() => { waiters.splice(waiters.indexOf(waiter), 1); waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`)); }, 5000); waiters.push(waiter); }); }, 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()); }); }