| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836 |
- 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: '<imap-hello@example.net>',
- rawMessage: [
- 'From: Alice <alice@example.net>',
- 'To: admin@imap.example',
- 'Subject: IMAP hello',
- 'Message-ID: <imap-hello@example.net>',
- '',
- '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: '<flags@example.net>',
- 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: offsetMailbox } = createMailboxFixture('search-offset.example', 'search-offset-user');
- createInboundMessage(offsetMailbox, {
- sender: 'offset@example.net',
- recipients: ['admin@search-offset.example'],
- subject: 'UID offset',
- messageId: '<offset@search-offset.example>',
- rawMessage: 'From: offset@example.net\r\nTo: admin@search-offset.example\r\nSubject: UID offset\r\n\r\nOffset body.',
- textBody: 'Offset body.'
- });
- const { mailbox } = createMailboxFixture('search.example', 'search-user');
- const { message: seenMessage } = createImportedInboundMessage(mailbox, {
- importSource: 'imap-search-test',
- sourceKey: 'seen-message',
- sender: 'seen@example.net',
- recipients: ['admin@search.example'],
- subject: 'Already seen',
- messageId: '<seen@search.example>',
- 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: '<unseen@search.example>',
- 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 sequenceNumber = await client.command('A10A SEARCH 2', /A10A (?:OK|NO|BAD)[^\r\n]*\r\n$/);
- const sequenceRangeUnseen = await client.command('A10B SEARCH 1:* UNSEEN', /A10B (?:OK|NO|BAD)[^\r\n]*\r\n$/);
- const uidSequenceNumber = await client.command('A10C UID SEARCH 2', /A10C (?:OK|NO|BAD)[^\r\n]*\r\n$/);
- const uidSequenceOutOfRange = await client.command('A10D UID SEARCH 16', /A10D (?:OK|NO|BAD)[^\r\n]*\r\n$/);
- const uidRangeUnseen = await client.command('A10E UID SEARCH 1:2 UNSEEN', /A10E (?:OK|NO|BAD)[^\r\n]*\r\n$/);
- const uidList = await client.command('A10F UID SEARCH 1,2', /A10F (?:OK|NO|BAD)[^\r\n]*\r\n$/);
- const uidCriterion = await client.command(`A10G UID SEARCH UID ${unseenMessage.id}`, /A10G (?:OK|NO|BAD)[^\r\n]*\r\n$/);
- const sequenceUidCriterion = await client.command(`A10H SEARCH UID ${unseenMessage.id}`, /A10H (?: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]);
- assertImapSearchResult(sequenceNumber, [2]);
- assertImapSearchResult(sequenceRangeUnseen, [2]);
- assertImapSearchResult(uidSequenceNumber, [unseenMessage.id]);
- assertImapSearchResult(uidSequenceOutOfRange, []);
- assertImapSearchResult(uidRangeUnseen, [unseenMessage.id]);
- assertImapSearchResult(uidList, [seenMessage.id, unseenMessage.id]);
- assertImapSearchResult(uidCriterion, [unseenMessage.id]);
- assertImapSearchResult(sequenceUidCriterion, [2]);
- 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: '<mime-message@example.net>',
- rawMessage: [
- 'From: Alice <alice@example.net>',
- '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',
- '',
- '<p>HTML message body.</p>',
- '--mailhub-boundary--',
- ''
- ].join('\r\n'),
- textBody: 'Plain message body.',
- htmlBody: '<p>HTML message body.</p>'
- });
- 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<p>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 <alice@example.net>',
- 'To: admin@lf.example',
- 'Subject: LF-only imported',
- ' continuation',
- 'Message-ID: <lf-only@example.net>',
- '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: '<lf-only@example.net>',
- 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 <alice@example.net>',
- 'To: admin@lf.example',
- 'Subject: LF-only imported',
- ' continuation',
- 'Message-ID: <lf-only@example.net>',
- '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 <alice@example.net>',
- 'To: admin@lf.example',
- 'Subject: LF-only imported',
- ' continuation',
- 'Message-ID: <lf-only@example.net>',
- '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 <bob@example.net>',
- '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 <admin@append.example>',
- 'To: Bob <bob@example.net>',
- 'Subject: =?UTF-8?Q?=E6=A0=B8=E4=BA=91?=',
- ' =?UTF-8?Q?=E8=AE=A1=E7=AE=97?=',
- 'Message-ID: <sent-copy@append.example>',
- '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',
- '',
- '<p>Sent HTML body.</p>',
- '--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 <admin@append.example>',
- 'To: Bob <bob@example.net>',
- '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 <bob@example.net>',
- 'To: admin@pop3.example',
- 'Subject: POP3 hello',
- 'Message-ID: <pop3-hello@example.net>',
- '',
- 'Hello through POP3.'
- ].join('\r\n');
- const firstMessage = createInboundMessage(mailbox, {
- sender: 'bob@example.net',
- recipients: ['admin@pop3.example'],
- subject: 'POP3 hello',
- messageId: '<pop3-hello@example.net>',
- rawMessage: firstRawMessage,
- textBody: 'Hello through POP3.'
- });
- const latin1RawMessage = Buffer.concat([
- Buffer.from([
- 'From: Alice <alice@example.net>',
- '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());
- });
- }
|