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