| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453 |
- 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,
- getInboundMessage,
- 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: '<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/);
- 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('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 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 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 () => {
- 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');
- 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('\r\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([latin1RawMessage, 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/);
- const retrieved = await client.command('RETR 1', /\r\n\.\r\n/);
- assert.match(retrieved, /Subject: POP3 hello/);
- assert.match(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);
- }
- });
- 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());
- });
- }
|