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 SEARCH filters seen state and rejects invalid contexts or criteria', async () => { initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-search-test-')), 'mail-access-secret'); const { mailbox } = createMailboxFixture('search.example', 'search-user'); createImportedInboundMessage(mailbox, { importSource: 'imap-search-test', sourceKey: 'seen-message', sender: 'seen@example.net', recipients: ['admin@search.example'], subject: 'Already seen', messageId: '', rawMessageBytes: Buffer.from('From: seen@example.net\r\nTo: admin@search.example\r\nSubject: Already seen\r\n\r\nSeen body.', 'utf8'), flags: ['\\Seen'], receivedAt: '2026-07-15T01:00:00.000Z' }); const unseenMessage = createInboundMessage(mailbox, { sender: 'unseen@example.net', recipients: ['admin@search.example'], subject: 'Still unread', messageId: '', rawMessage: 'From: unseen@example.net\r\nTo: admin@search.example\r\nSubject: Still unread\r\n\r\nUnread body.', textBody: 'Unread body.' }); const [server] = startMailboxAccessServers({ hostname: 'mail.search.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@search.example" "mailbox-pass-123"', /A1 OK/); const searchBeforeSelect = await client.command('A2 SEARCH ALL', /A2 (?:OK|NO|BAD)[^\r\n]*\r\n$/); const uidSearchBeforeSelect = await client.command('A3 UID SEARCH ALL', /A3 (?:OK|NO|BAD)[^\r\n]*\r\n$/); await client.command('A4 SELECT INBOX', /A4 OK/); const all = await client.command('A5 SEARCH ALL', /A5 (?:OK|NO|BAD)[^\r\n]*\r\n$/); const unseen = await client.command('A6 SEARCH UNSEEN', /A6 (?:OK|NO|BAD)[^\r\n]*\r\n$/); const seen = await client.command('A7 SEARCH SEEN', /A7 (?:OK|NO|BAD)[^\r\n]*\r\n$/); const allUnseen = await client.command('A8 SEARCH ALL UNSEEN', /A8 (?:OK|NO|BAD)[^\r\n]*\r\n$/); const uidUnseen = await client.command('A9 UID SEARCH UNSEEN', /A9 (?:OK|NO|BAD)[^\r\n]*\r\n$/); const uidCharsetUnseen = await client.command('A10 UID SEARCH CHARSET UTF-8 UNSEEN', /A10 (?:OK|NO|BAD)[^\r\n]*\r\n$/); const stored = await client.command( `A11 UID STORE ${unseenMessage.id} +FLAGS.SILENT (\\Seen)`, /A11 (?:OK|NO|BAD)[^\r\n]*\r\n$/ ); const unseenAfterStore = await client.command('A12 SEARCH UNSEEN', /A12 (?:OK|NO|BAD)[^\r\n]*\r\n$/); const seenAfterStore = await client.command('A13 SEARCH SEEN', /A13 (?:OK|NO|BAD)[^\r\n]*\r\n$/); const returnCriteria = await client.command('A14 SEARCH RETURN (ALL) ALL', /A14 (?:OK|NO|BAD)[^\r\n]*\r\n$/); const unknownCriteria = await client.command('A15 SEARCH FROBNICATE', /A15 (?:OK|NO|BAD)[^\r\n]*\r\n$/); await client.command('A16 LOGOUT', /A16 OK/); assert.match(searchBeforeSelect, /^A2 (?:NO|BAD) /m); assert.doesNotMatch(searchBeforeSelect, /^\* SEARCH/m); assert.match(uidSearchBeforeSelect, /^A3 (?:NO|BAD) /m); assert.doesNotMatch(uidSearchBeforeSelect, /^\* SEARCH/m); assertImapSearchResult(all, [1, 2]); assertImapSearchResult(unseen, [2]); assertImapSearchResult(seen, [1]); assertImapSearchResult(allUnseen, [2]); assertImapSearchResult(uidUnseen, [unseenMessage.id]); assertImapSearchResult(uidCharsetUnseen, [unseenMessage.id]); assert.match(stored, /^A11 OK STORE completed\r?$/m); assert.doesNotMatch(stored, /^\* \d+ FETCH/m); assertImapSearchResult(unseenAfterStore, []); assertImapSearchResult(seenAfterStore, [1, 2]); assert.match(returnCriteria, /^A14 BAD /m); assert.doesNotMatch(returnCriteria, /^\* SEARCH/m); assert.match(unknownCriteria, /^A15 BAD /m); assert.doesNotMatch(unknownCriteria, /^\* SEARCH/m); } 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 LF-only Maildir headers and text sections to Roundcube', async () => { initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-lf-test-')), 'mail-access-secret'); const { mailbox } = createMailboxFixture('lf.example', 'lf-user'); const rawMessageBytes = Buffer.from([ 'From: Alice ', 'To: admin@lf.example', 'Subject: LF-only imported', ' continuation', 'Message-ID: ', 'Content-Type: text/plain; charset=UTF-8', 'X-Not-Selected: private metadata', '', 'LF-only body.', 'Second line.' ].join('\n'), 'utf8'); createImportedInboundMessage(mailbox, { importSource: 'vesta:lf-only', sourceKey: 'lf-only-message', sender: 'alice@example.net', recipients: ['admin@lf.example'], subject: 'LF-only imported continuation', messageId: '', rawMessageBytes, receivedAt: '2026-07-14T06:19:40.000Z' }); const [server] = startMailboxAccessServers({ hostname: 'mail.lf.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@lf.example" "mailbox-pass-123"', /A1 OK/); await client.command('A2 SELECT INBOX', /A2 OK/); const headerFieldsLabel = 'BODY[HEADER.FIELDS (DATE FROM TO CC REPLY-TO SUBJECT MESSAGE-ID REFERENCES CONTENT-TYPE X-PRIORITY X-MSMMAIL-PRIORITY IMPORTANCE)]'; const headerFieldsResponse = await client.commandBytes( `A3 UID FETCH 1 (UID FLAGS RFC822.SIZE INTERNALDATE BODY.PEEK[HEADER.FIELDS (DATE FROM TO CC REPLY-TO SUBJECT MESSAGE-ID REFERENCES CONTENT-TYPE X-PRIORITY X-MSMMAIL-PRIORITY IMPORTANCE)])`, /A3 OK FETCH completed\r\n$/ ); assert.deepEqual(extractFetchLiteral(headerFieldsResponse, headerFieldsLabel), Buffer.from([ 'From: Alice ', 'To: admin@lf.example', 'Subject: LF-only imported', ' continuation', 'Message-ID: ', 'Content-Type: text/plain; charset=UTF-8', '', '' ].join('\r\n'), 'utf8')); const fullHeaderResponse = await client.commandBytes( 'A4 UID FETCH 1 (RFC822.HEADER)', /A4 OK FETCH completed\r\n$/ ); assert.deepEqual(extractFetchLiteral(fullHeaderResponse, 'RFC822.HEADER'), Buffer.from([ 'From: Alice ', 'To: admin@lf.example', 'Subject: LF-only imported', ' continuation', 'Message-ID: ', 'Content-Type: text/plain; charset=UTF-8', 'X-Not-Selected: private metadata', '', '' ].join('\r\n'), 'utf8')); const expectedBody = Buffer.from('LF-only body.\nSecond line.', 'utf8'); const rfc822TextResponse = await client.commandBytes( 'A5 UID FETCH 1 (RFC822.TEXT)', /A5 OK FETCH completed\r\n$/ ); assert.deepEqual(extractFetchLiteral(rfc822TextResponse, 'RFC822.TEXT'), expectedBody); const bodyTextResponse = await client.commandBytes( 'A6 UID FETCH 1 (BODY.PEEK[TEXT])', /A6 OK FETCH completed\r\n$/ ); assert.deepEqual(extractFetchLiteral(bodyTextResponse, 'BODY[TEXT]'), expectedBody); const fullMessageResponse = await client.commandBytes( 'A7 UID FETCH 1 (BODY.PEEK[])', /A7 OK FETCH completed\r\n$/ ); assert.deepEqual(extractFetchLiteral(fullMessageResponse, 'BODY[]'), rawMessageBytes); await client.command('A8 LOGOUT', /A8 OK/); } 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 extractFetchLiteral(response, label) { const bytes = Buffer.isBuffer(response) ? response : Buffer.from(response || ''); const marker = Buffer.from(`${label} {`, 'ascii'); const markerIndex = bytes.indexOf(marker); assert.notEqual(markerIndex, -1, `Missing ${label} literal marker`); const sizeStart = markerIndex + marker.length; const sizeEndMarker = Buffer.from('}\r\n', 'ascii'); const sizeEnd = bytes.indexOf(sizeEndMarker, sizeStart); assert.notEqual(sizeEnd, -1, `Missing ${label} literal size terminator`); const size = Number(bytes.subarray(sizeStart, sizeEnd).toString('ascii')); assert.equal(Number.isInteger(size) && size >= 0, true, `Invalid ${label} literal size`); const literalStart = sizeEnd + sizeEndMarker.length; const literalEnd = literalStart + size; assert.ok(literalEnd <= bytes.length, `Truncated ${label} literal`); assert.deepEqual(bytes.subarray(literalEnd, literalEnd + 5), Buffer.from('\r\n)\r\n', 'ascii')); return bytes.subarray(literalStart, literalEnd); } function assertImapSearchResult(response, expected) { assert.match(response, /^\S+ OK SEARCH completed\r?$/m); const match = response.match(/^\* SEARCH(?: ([0-9 ]+))?\r?$/m); assert.ok(match, `Missing SEARCH response in: ${response}`); const actual = String(match[1] || '') .split(/\s+/) .filter(Boolean) .map(Number); assert.deepEqual(actual, expected); } 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()); }); }