| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063 |
- 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,
- createUser,
- createWebhook,
- deleteInboundMailboxWithMessageTransfer,
- initDatabase,
- listInboundMessages,
- listWebhookDeliveries,
- prepareInboundMailboxDeletion,
- replaceInboundMailboxGrants,
- saveSmtpRelay,
- saveSmtpCredential,
- updateDomain
- } from '../src/db.js';
- import { createDkimKeyPair } from '../src/dkim.js';
- import { sendViaSmtp } from '../src/mailer.js';
- import { readMaildirMessage, scanMaildirMailbox } from '../src/maildir-store.js';
- import { startSubmissionServer } from '../src/submission.js';
- test('SMTP accepts unauthenticated inbound mail for local mailboxes', async () => {
- const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-'));
- const maildirRoot = path.join(dataDir, 'maildir');
- initDatabase(dataDir, 'inbound-secret');
- const user = createUser({ username: 'inbound-smtp', email: 'inbound-smtp@example.com', password: 'password123' });
- createDomain(user.id, {
- domain: 'inbound.example',
- selector: 'mh',
- verificationToken: 'verify',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.inbound.example',
- sendingIp: '192.0.2.10',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- const mailbox = createInboundMailbox(user.id, { address: 'support@inbound.example', displayName: 'Support' });
- const [server] = startSubmissionServer({
- enabled: true,
- listeners: [{ port: 0, protocol: 'smtp' }],
- hostname: 'mx.inbound.example',
- allowInsecureAuth: true,
- inboundEnabled: true,
- maildirEnabled: true,
- maildirRoot,
- relayHost: '',
- relayPort: 25,
- relaySecure: false,
- relayUsername: '',
- relayPassword: '',
- relayHelo: 'mx.inbound.example'
- });
- await waitForListening(server);
- try {
- const rawHeaders = [
- 'From: Alice <alice@example.net>',
- 'To: Support <support@inbound.example>',
- 'Subject: Hello inbound SMTP',
- 'Message-ID: <hello-inbound@example.net>',
- 'Content-Type: text/plain; charset=ISO-8859-1',
- '',
- ''
- ].join('\r\n');
- const rawMessage = Buffer.concat([
- Buffer.from(rawHeaders, 'ascii'),
- Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x63, 0x61, 0x66, 0xe9, 0x2e, 0x0d, 0x0a])
- ]);
- const response = await sendViaSmtp({
- host: '127.0.0.1',
- port: server.address().port,
- secure: false,
- username: '',
- password: '',
- helo: 'sender.example.net',
- mailFrom: 'alice@example.net',
- recipients: ['support@inbound.example'],
- rawMessage
- });
- assert.match(response.message, /Message accepted/i);
- const [message] = listInboundMessages(user.id);
- assert.equal(message.sender, 'alice@example.net');
- assert.deepEqual(message.recipients, ['support@inbound.example']);
- assert.equal(message.subject, 'Hello inbound SMTP');
- assert.equal(message.preview, 'Hello café.');
- const [maildirMessage] = await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address });
- assert.ok(maildirMessage.storageKey.startsWith('mhsmtp-'));
- assert.equal(maildirMessage.folder, 'INBOX');
- const maildirBytes = (await readMaildirMessage(maildirMessage)).bytes;
- assert.equal(maildirBytes.includes(Buffer.from([0x63, 0x61, 0x66, 0xe9, 0x2e])), true);
- assert.equal(maildirBytes.includes(Buffer.from('café', 'utf8')), false);
- } finally {
- await closeServer(server);
- }
- });
- test('SMTP queues a mailbox receipt webhook only after the inbound message is stored', async () => {
- initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-webhook-')), 'inbound-secret');
- const user = createUser({ username: 'inbound-webhook', email: 'inbound-webhook@example.com', password: 'password123' });
- createDomain(user.id, {
- domain: 'receipt-hook.example',
- selector: 'mh',
- verificationToken: 'verify',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.receipt-hook.example',
- sendingIp: '192.0.2.12',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- const mailbox = createInboundMailbox(user.id, { address: 'support@receipt-hook.example' });
- createWebhook(user.id, {
- name: 'Support received',
- url: 'https://hooks.example.com/receipt',
- events: ['received'],
- mailboxId: mailbox.id
- });
- const [server] = startSubmissionServer({
- enabled: true,
- listeners: [{ port: 0, protocol: 'smtp' }],
- hostname: 'mx.receipt-hook.example',
- allowInsecureAuth: true,
- inboundEnabled: true
- });
- await waitForListening(server);
- try {
- await sendViaSmtp({
- host: '127.0.0.1',
- port: server.address().port,
- secure: false,
- username: '',
- password: '',
- helo: 'sender.example.net',
- mailFrom: 'sender@example.net',
- recipients: ['support@receipt-hook.example'],
- rawMessage: [
- 'Message-ID: <smtp-receipt-hook@example.net>',
- 'From: sender@example.net',
- 'To: support@receipt-hook.example',
- 'Subject: Stored before callback',
- '',
- 'Inbound body'
- ].join('\r\n')
- });
- const [message] = listInboundMessages(user.id);
- assert.ok(message);
- const [delivery] = listWebhookDeliveries(user.id, { eventType: 'received' });
- assert.ok(delivery);
- assert.equal(delivery.inboundMessageId, message.id);
- const payload = JSON.parse(delivery.payloadJson);
- assert.equal(payload.type, 'email.received');
- assert.equal(payload.data.message_id, '<smtp-receipt-hook@example.net>');
- assert.equal(payload.data.text, 'Inbound body');
- } finally {
- await closeServer(server);
- }
- });
- test('SMTP rejects unauthenticated inbound mail for unknown recipients', async () => {
- initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-reject-')), 'inbound-secret');
- const [server] = startSubmissionServer({
- enabled: true,
- listeners: [{ port: 0, protocol: 'smtp' }],
- hostname: 'mx.inbound.example',
- allowInsecureAuth: true,
- inboundEnabled: true
- });
- await waitForListening(server);
- try {
- const transcript = await smtpTranscript(server.address().port, [
- 'EHLO sender.example.net',
- 'MAIL FROM:<alice@example.net>',
- 'RCPT TO:<nobody@external.example>'
- ]);
- assert.match(transcript.at(-1), /^550 /);
- assert.equal(listInboundMessages(1).length, 0);
- } finally {
- await closeServer(server);
- }
- });
- test('SMTP rechecks a cached RCPT route and rejects DATA after the mailbox is deleted', async () => {
- initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-delete-race-')), 'inbound-secret');
- const user = createUser({ username: 'delete-race', email: 'delete-race@example.com', password: 'password123' });
- createDomain(user.id, {
- domain: 'delete-race.example',
- selector: 'mh',
- verificationToken: 'verify',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.delete-race.example',
- sendingIp: '192.0.2.18',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- const mailbox = createInboundMailbox(user.id, { address: 'support@delete-race.example' });
- const [server] = startSubmissionServer({
- enabled: true,
- listeners: [{ port: 0, protocol: 'smtp' }],
- hostname: 'mx.delete-race.example',
- allowInsecureAuth: true,
- inboundEnabled: true
- });
- await waitForListening(server);
- try {
- const transcript = await smtpTranscript(server.address().port, [
- 'EHLO sender.example.net',
- 'MAIL FROM:<alice@example.net>',
- 'RCPT TO:<support@delete-race.example>',
- () => {
- prepareInboundMailboxDeletion(user.id, mailbox.id, null, {
- confirmAddress: mailbox.address
- });
- deleteInboundMailboxWithMessageTransfer(user.id, mailbox.id, null);
- return 'DATA';
- },
- [
- 'From: alice@example.net',
- 'To: support@delete-race.example',
- 'Subject: Must retry',
- '',
- 'This message must not recreate the deleted mailbox Maildir.',
- '.'
- ].join('\r\n')
- ]);
- assert.match(transcript.at(-1), /^451 /);
- assert.equal(listInboundMessages(user.id).length, 0);
- } finally {
- await closeServer(server);
- }
- });
- test('SMTP authenticates with a mailbox account address and password', async () => {
- initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-mailbox-auth-')), 'inbound-secret');
- const user = createUser({ username: 'mailbox-auth', email: 'mailbox-auth@example.com', password: 'password123' });
- createDomain(user.id, {
- domain: 'authmail.example',
- selector: 'mh',
- verificationToken: 'verify',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.authmail.example',
- sendingIp: '192.0.2.16',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- createDomain(user.id, {
- domain: 'other-authmail.example',
- selector: 'mh',
- verificationToken: 'verify-other',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.other-authmail.example',
- sendingIp: '192.0.2.17',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- createInboundMailbox(user.id, {
- address: 'admin@authmail.example',
- password: 'mailbox-pass-123',
- aliases: ['sales']
- });
- const [server] = startSubmissionServer({
- enabled: true,
- listeners: [{ port: 0, protocol: 'smtp' }],
- hostname: 'mx.authmail.example',
- allowInsecureAuth: true,
- inboundEnabled: true
- });
- await waitForListening(server);
- try {
- const auth = Buffer.from('\u0000admin@authmail.example\u0000mailbox-pass-123').toString('base64');
- const ownSender = await smtpTranscript(server.address().port, [
- 'EHLO sender.example.net',
- `AUTH PLAIN ${auth}`,
- 'MAIL FROM:<admin@authmail.example>'
- ]);
- assert.match(ownSender.at(-1), /^250 /);
- const aliasSender = await smtpTranscript(server.address().port, [
- 'EHLO sender.example.net',
- `AUTH PLAIN ${auth}`,
- 'MAIL FROM:<sales@authmail.example>'
- ]);
- assert.match(aliasSender.at(-1), /^250 /);
- const crossMailboxSender = await smtpTranscript(server.address().port, [
- 'EHLO sender.example.net',
- `AUTH PLAIN ${auth}`,
- 'MAIL FROM:<other@other-authmail.example>'
- ]);
- assert.match(crossMailboxSender.at(-1), /^553 /);
- const crossMailboxHeader = await smtpTranscript(server.address().port, [
- 'EHLO sender.example.net',
- `AUTH PLAIN ${auth}`,
- 'MAIL FROM:<admin@authmail.example>',
- 'RCPT TO:<recipient@example.net>',
- 'DATA',
- [
- 'From: Other <other@other-authmail.example>',
- 'To: recipient@example.net',
- 'Subject: blocked mailbox impersonation',
- '',
- 'This message must not be relayed.',
- '.'
- ].join('\r\n')
- ]);
- assert.match(crossMailboxHeader.at(-1), /^553 /);
- } finally {
- await closeServer(server);
- }
- });
- test('authenticated mailbox SMTP sessions are revoked before MAIL and DATA after mailbox deletion', async () => {
- initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-mailbox-delete-session-')), 'inbound-secret');
- const user = createUser({
- username: 'deleted-session-smtp',
- email: 'deleted-session-smtp@example.com',
- password: 'password123'
- });
- createDomain(user.id, {
- domain: 'deleted-session-smtp.example',
- selector: 'mh',
- verificationToken: 'verify',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.deleted-session-smtp.example',
- sendingIp: '192.0.2.18',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- const mailBoundaryMailbox = createInboundMailbox(user.id, {
- address: 'mail-boundary@deleted-session-smtp.example',
- password: 'mailbox-pass-123'
- });
- const dataBoundaryMailbox = createInboundMailbox(user.id, {
- address: 'data-boundary@deleted-session-smtp.example',
- password: 'mailbox-pass-123'
- });
- const [server] = startSubmissionServer({
- enabled: true,
- listeners: [{ port: 0, protocol: 'smtp' }],
- hostname: 'mx.deleted-session-smtp.example',
- allowInsecureAuth: true,
- inboundEnabled: true
- });
- await waitForListening(server);
- const deleteMailbox = (mailbox) => {
- prepareInboundMailboxDeletion(user.id, mailbox.id, null, {
- confirmAddress: mailbox.address
- });
- deleteInboundMailboxWithMessageTransfer(user.id, mailbox.id, null);
- };
- try {
- const mailAuth = Buffer.from(
- `\u0000${mailBoundaryMailbox.address}\u0000mailbox-pass-123`
- ).toString('base64');
- const mailTranscript = await smtpTranscript(server.address().port, [
- 'EHLO sender.example.net',
- `AUTH PLAIN ${mailAuth}`,
- () => {
- deleteMailbox(mailBoundaryMailbox);
- return `MAIL FROM:<${mailBoundaryMailbox.address}>`;
- }
- ]);
- assert.match(mailTranscript.at(-1), /^530 Authentication no longer valid$/);
- const dataAuth = Buffer.from(
- `\u0000${dataBoundaryMailbox.address}\u0000mailbox-pass-123`
- ).toString('base64');
- const dataTranscript = await smtpTranscript(server.address().port, [
- 'EHLO sender.example.net',
- `AUTH PLAIN ${dataAuth}`,
- `MAIL FROM:<${dataBoundaryMailbox.address}>`,
- 'RCPT TO:<recipient@example.net>',
- () => {
- deleteMailbox(dataBoundaryMailbox);
- return 'DATA';
- }
- ]);
- assert.match(dataTranscript.at(-1), /^530 Authentication no longer valid$/);
- } finally {
- await closeServer(server);
- }
- });
- test('SMTP relays only the authenticated mailbox and aliases from a shared domain', async () => {
- initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-shared-domain-')), 'inbound-secret');
- const owner = createUser({ username: 'shared-domain-owner', email: 'owner@example.com', password: 'password123' });
- const member = createUser({ username: 'shared-domain-member', email: 'member@example.com', password: 'password123' });
- saveSmtpCredential(member.id, { username: 'shared-domain-member-smtp', password: 'member-smtp-pass' });
- const keys = createDkimKeyPair();
- const domain = createDomain(owner.id, {
- domain: 'shared-authmail.example',
- selector: 'mh',
- verificationToken: 'verify-shared',
- dkimPublic: keys.publicKey,
- dkimPrivate: keys.privateKey,
- senderHost: 'mail.shared-authmail.example',
- sendingIp: '192.0.2.18',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- const memberDomainKeys = createDkimKeyPair();
- createDomain(member.id, {
- domain: 'member-owned.example',
- selector: 'mh',
- verificationToken: 'verify-member-owned',
- dkimPublic: memberDomainKeys.publicKey,
- dkimPrivate: memberDomainKeys.privateKey,
- senderHost: 'mail.member-owned.example',
- sendingIp: '192.0.2.19',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- createInboundMailbox(owner.id, {
- address: 'owner@shared-authmail.example',
- password: 'owner-mailbox-pass'
- });
- updateDomain(domain.id, owner.id, { mailboxSignupEnabled: true });
- createInboundMailbox(member.id, {
- address: 'member@shared-authmail.example',
- password: 'member-mailbox-pass',
- aliases: ['member-alias']
- });
- const relay = await startFakeSmtpServer();
- const [server] = startSubmissionServer({
- enabled: true,
- listeners: [{ port: 0, protocol: 'smtp' }],
- hostname: 'mx.shared-authmail.example',
- allowInsecureAuth: true,
- inboundEnabled: true,
- relayHost: '127.0.0.1',
- relayPort: relay.port,
- relaySecure: false,
- relayUsername: '',
- relayPassword: '',
- relayHelo: 'mail.shared-authmail.example'
- });
- await waitForListening(server);
- try {
- for (const sender of ['member@shared-authmail.example', 'member-alias@shared-authmail.example']) {
- const response = await sendViaSmtp({
- host: '127.0.0.1',
- port: server.address().port,
- secure: false,
- username: 'member@shared-authmail.example',
- password: 'member-mailbox-pass',
- helo: 'client.example.net',
- mailFrom: sender,
- recipients: ['recipient@example.net'],
- rawMessage: [
- `From: ${sender}`,
- 'To: recipient@example.net',
- 'Subject: Shared mailbox submission',
- '',
- 'Shared mailbox body'
- ].join('\r\n')
- });
- assert.match(response.message, /Message queued/i);
- }
- assert.equal(relay.messages.length, 2);
- assert.ok(relay.messages.every((message) => /^DKIM-Signature:/m.test(message)));
- assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<member@shared-authmail.example>'));
- assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<member-alias@shared-authmail.example>'));
- const auth = Buffer.from('\u0000member@shared-authmail.example\u0000member-mailbox-pass').toString('base64');
- const otherMailboxSender = await smtpTranscript(server.address().port, [
- 'EHLO client.example.net',
- `AUTH PLAIN ${auth}`,
- 'MAIL FROM:<owner@shared-authmail.example>'
- ]);
- assert.match(otherMailboxSender.at(-1), /^553 /);
- const otherMailboxHeader = await smtpTranscript(server.address().port, [
- 'EHLO client.example.net',
- `AUTH PLAIN ${auth}`,
- 'MAIL FROM:<member@shared-authmail.example>',
- 'RCPT TO:<recipient@example.net>',
- 'DATA',
- [
- 'From: owner@shared-authmail.example',
- 'To: recipient@example.net',
- 'Subject: Block shared-domain impersonation',
- '',
- 'This message must not be relayed.',
- '.'
- ].join('\r\n')
- ]);
- assert.match(otherMailboxHeader.at(-1), /^553 /);
- assert.equal(relay.messages.length, 2);
- for (const sender of ['member@shared-authmail.example', 'member-alias@shared-authmail.example']) {
- const response = await sendViaSmtp({
- host: '127.0.0.1',
- port: server.address().port,
- secure: false,
- username: 'shared-domain-member-smtp',
- password: 'member-smtp-pass',
- helo: 'client.example.net',
- mailFrom: sender,
- recipients: ['recipient@example.net'],
- rawMessage: [
- `From: ${sender}`,
- 'To: recipient@example.net',
- 'Subject: Shared mailbox account credential submission',
- '',
- 'Shared mailbox body'
- ].join('\r\n')
- });
- assert.match(response.message, /Message queued/i);
- }
- const ownDomainResponse = await sendViaSmtp({
- host: '127.0.0.1',
- port: server.address().port,
- secure: false,
- username: 'shared-domain-member-smtp',
- password: 'member-smtp-pass',
- helo: 'client.example.net',
- mailFrom: 'any-local-part@member-owned.example',
- recipients: ['recipient@example.net'],
- rawMessage: [
- 'From: any-local-part@member-owned.example',
- 'To: recipient@example.net',
- 'Subject: Owned domain account credential submission',
- '',
- 'Owned domain body'
- ].join('\r\n')
- });
- assert.match(ownDomainResponse.message, /Message queued/i);
- assert.equal(relay.messages.length, 5);
- const accountAuth = Buffer.from('\u0000shared-domain-member-smtp\u0000member-smtp-pass').toString('base64');
- const accountOtherMailboxSender = await smtpTranscript(server.address().port, [
- 'EHLO client.example.net',
- `AUTH PLAIN ${accountAuth}`,
- 'MAIL FROM:<owner@shared-authmail.example>'
- ]);
- assert.match(accountOtherMailboxSender.at(-1), /^553 /);
- const accountOtherMailboxHeader = await smtpTranscript(server.address().port, [
- 'EHLO client.example.net',
- `AUTH PLAIN ${accountAuth}`,
- 'MAIL FROM:<member@shared-authmail.example>',
- 'RCPT TO:<recipient@example.net>',
- 'DATA',
- [
- 'From: owner@shared-authmail.example',
- 'To: recipient@example.net',
- 'Subject: Block account shared-domain impersonation',
- '',
- 'This message must not be relayed.',
- '.'
- ].join('\r\n')
- ]);
- assert.match(accountOtherMailboxHeader.at(-1), /^553 /);
- assert.equal(relay.messages.length, 5);
- } finally {
- await closeServer(server);
- await relay.close();
- }
- });
- test('account SMTP credentials honor shared mailbox send grants and revocation', async () => {
- initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-account-grant-')), 'inbound-secret');
- const owner = createUser({ username: 'account-grant-owner', email: 'grant-owner@example.com', password: 'password123' });
- const member = createUser({ username: 'account-grant-member', email: 'grant-member@example.com', password: 'password123' });
- saveSmtpCredential(member.id, { username: 'account-grant-member-smtp', password: 'member-smtp-pass' });
- const ownerRelay = await startFakeSmtpServer();
- const fallbackRelay = await startFakeSmtpServer();
- const ownerRelayConfig = saveSmtpRelay(owner.id, {
- name: 'Owner shared mailbox relay',
- host: '127.0.0.1',
- port: ownerRelay.port,
- secure: false,
- username: '',
- password: '',
- helo: 'mail.account-grant.example'
- });
- const keys = createDkimKeyPair();
- createDomain(owner.id, {
- domain: 'account-grant.example',
- selector: 'mh',
- smtpRelayId: ownerRelayConfig.id,
- verificationToken: 'verify-account-grant',
- dkimPublic: keys.publicKey,
- dkimPrivate: keys.privateKey,
- senderHost: 'mail.account-grant.example',
- sendingIp: '192.0.2.21',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- const sharedMailbox = createInboundMailbox(owner.id, {
- address: 'shared@account-grant.example',
- password: 'shared-mailbox-pass',
- aliases: ['shared-alias']
- });
- createInboundMailbox(owner.id, {
- address: 'private@account-grant.example',
- password: 'private-mailbox-pass'
- });
- replaceInboundMailboxGrants(sharedMailbox.id, [
- { userId: member.id, permissions: ['send'] }
- ]);
- const [server] = startSubmissionServer({
- enabled: true,
- listeners: [{ port: 0, protocol: 'smtp' }],
- hostname: 'mx.account-grant.example',
- allowInsecureAuth: true,
- inboundEnabled: true,
- relayHost: '127.0.0.1',
- relayPort: fallbackRelay.port,
- relaySecure: false,
- relayUsername: '',
- relayPassword: '',
- relayHelo: 'mail.account-grant.example'
- });
- await waitForListening(server);
- try {
- for (const sender of ['shared@account-grant.example', 'shared-alias@account-grant.example']) {
- const response = await sendViaSmtp({
- host: '127.0.0.1',
- port: server.address().port,
- secure: false,
- username: 'account-grant-member-smtp',
- password: 'member-smtp-pass',
- helo: 'client.example.net',
- mailFrom: sender,
- recipients: ['recipient@example.net'],
- rawMessage: [
- `From: ${sender}`,
- 'To: recipient@example.net',
- 'Subject: Account credential shared mailbox grant',
- '',
- 'Shared mailbox grant body'
- ].join('\r\n')
- });
- assert.match(response.message, /Message queued/i);
- }
- assert.equal(ownerRelay.messages.length, 2);
- assert.equal(fallbackRelay.messages.length, 0);
- assert.ok(ownerRelay.messages.every((message) => /^DKIM-Signature:/m.test(message)));
- assert.ok(ownerRelay.commands.includes('MAIL FROM:<shared@account-grant.example>'));
- assert.ok(ownerRelay.commands.includes('MAIL FROM:<shared-alias@account-grant.example>'));
- const auth = Buffer.from('\u0000account-grant-member-smtp\u0000member-smtp-pass').toString('base64');
- const unauthorized = await smtpTranscript(server.address().port, [
- 'EHLO client.example.net',
- `AUTH PLAIN ${auth}`,
- 'MAIL FROM:<private@account-grant.example>'
- ]);
- assert.match(unauthorized.at(-1), /^553 /);
- replaceInboundMailboxGrants(sharedMailbox.id, []);
- for (const sender of ['shared@account-grant.example', 'shared-alias@account-grant.example']) {
- const revoked = await smtpTranscript(server.address().port, [
- 'EHLO client.example.net',
- `AUTH PLAIN ${auth}`,
- `MAIL FROM:<${sender}>`
- ]);
- assert.match(revoked.at(-1), /^553 /);
- }
- assert.equal(ownerRelay.messages.length, 2);
- assert.equal(fallbackRelay.messages.length, 0);
- } finally {
- await closeServer(server);
- await ownerRelay.close();
- await fallbackRelay.close();
- }
- });
- test('SMTP routes unknown inbound recipients to the domain catch-all mailbox', async () => {
- initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-catchall-')), 'inbound-secret');
- const user = createUser({ username: 'catchall-smtp', email: 'catchall-smtp@example.com', password: 'password123' });
- const domain = createDomain(user.id, {
- domain: 'catchall.example',
- selector: 'mh',
- verificationToken: 'verify',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.catchall.example',
- sendingIp: '192.0.2.17',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- createInboundMailbox(user.id, { address: 'share@catchall.example' });
- updateDomain(domain.id, user.id, { catchAllAddress: 'share@catchall.example' });
- const [server] = startSubmissionServer({
- enabled: true,
- listeners: [{ port: 0, protocol: 'smtp' }],
- hostname: 'mx.catchall.example',
- allowInsecureAuth: true,
- inboundEnabled: true
- });
- await waitForListening(server);
- try {
- await sendViaSmtp({
- host: '127.0.0.1',
- port: server.address().port,
- secure: false,
- username: '',
- password: '',
- helo: 'sender.example.net',
- mailFrom: 'alice@example.net',
- recipients: ['missing@catchall.example'],
- rawMessage: 'From: alice@example.net\r\nSubject: Catch all\r\n\r\nHello catch-all.'
- });
- const [message] = listInboundMessages(user.id);
- assert.equal(message.mailboxAddress, 'share@catchall.example');
- assert.deepEqual(message.recipients, ['missing@catchall.example']);
- assert.equal(message.subject, 'Catch all');
- } finally {
- await closeServer(server);
- }
- });
- test('SMTP forwards inbound messages without storing them when keepForwarded is false', async () => {
- initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-forward-')), 'inbound-secret');
- const relay = await startFakeSmtpServer();
- const user = createUser({ username: 'forward-smtp', email: 'forward-smtp@example.com', password: 'password123' });
- createDomain(user.id, {
- domain: 'forward.example',
- selector: 'mh',
- verificationToken: 'verify',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.forward.example',
- sendingIp: '192.0.2.18',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- createInboundMailbox(user.id, {
- address: 'ops@forward.example',
- forwardTo: 'archive@example.net',
- keepForwarded: false
- });
- const [server] = startSubmissionServer({
- enabled: true,
- listeners: [{ port: 0, protocol: 'smtp' }],
- hostname: 'mx.forward.example',
- allowInsecureAuth: true,
- inboundEnabled: true,
- relayHost: '127.0.0.1',
- relayPort: relay.port,
- relaySecure: false,
- relayUsername: '',
- relayPassword: '',
- relayHelo: 'mx.forward.example'
- });
- await waitForListening(server);
- try {
- await sendViaSmtp({
- host: '127.0.0.1',
- port: server.address().port,
- secure: false,
- username: '',
- password: '',
- helo: 'sender.example.net',
- mailFrom: 'alice@example.net',
- recipients: ['ops@forward.example'],
- rawMessage: 'From: alice@example.net\r\nSubject: Forward only\r\n\r\nForward this.'
- });
- assert.equal(listInboundMessages(user.id).length, 0);
- assert.ok(relay.commands.includes('RCPT TO:<archive@example.net>'));
- assert.match(relay.messages[0], /Subject: Forward only/);
- } finally {
- await closeServer(server);
- await relay.close();
- }
- });
- test('SMTP stores each inbound recipient without exposing other envelope recipients', async () => {
- initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-multi-')), 'inbound-secret');
- const supportUser = createUser({ username: 'support-user', email: 'support@example.com', password: 'password123' });
- const privateUser = createUser({ username: 'private-user', email: 'private@example.com', password: 'password123' });
- createDomain(supportUser.id, {
- domain: 'support.example',
- selector: 'mh',
- verificationToken: 'verify',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.support.example',
- sendingIp: '192.0.2.12',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- createDomain(privateUser.id, {
- domain: 'private.example',
- selector: 'mh',
- verificationToken: 'verify',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.private.example',
- sendingIp: '192.0.2.13',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- createInboundMailbox(supportUser.id, { address: 'support@support.example' });
- createInboundMailbox(privateUser.id, { address: 'private@private.example' });
- const [server] = startSubmissionServer({
- enabled: true,
- listeners: [{ port: 0, protocol: 'smtp' }],
- hostname: 'mx.inbound.example',
- allowInsecureAuth: true,
- inboundEnabled: true
- });
- await waitForListening(server);
- try {
- const rawMessage = [
- 'From: Alice <alice@example.net>',
- 'To: Support <support@support.example>',
- 'Subject: Multi recipient',
- '',
- 'Hello both.',
- ''
- ].join('\r\n');
- await sendViaSmtp({
- host: '127.0.0.1',
- port: server.address().port,
- secure: false,
- username: '',
- password: '',
- helo: 'sender.example.net',
- mailFrom: 'alice@example.net',
- recipients: ['support@support.example', 'private@private.example'],
- rawMessage
- });
- assert.deepEqual(listInboundMessages(supportUser.id)[0].recipients, ['support@support.example']);
- assert.deepEqual(listInboundMessages(privateUser.id)[0].recipients, ['private@private.example']);
- } finally {
- await closeServer(server);
- }
- });
- test('SMTP accepts unauthenticated inbound bounces with an empty envelope sender', async () => {
- initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-bounce-')), 'inbound-secret');
- const user = createUser({ username: 'bounce-user', email: 'bounce@example.com', password: 'password123' });
- createDomain(user.id, {
- domain: 'bounce.example',
- selector: 'mh',
- verificationToken: 'verify',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.bounce.example',
- sendingIp: '192.0.2.14',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- createInboundMailbox(user.id, { address: 'postmaster@bounce.example' });
- const [server] = startSubmissionServer({
- enabled: true,
- listeners: [{ port: 0, protocol: 'smtp' }],
- hostname: 'mx.bounce.example',
- allowInsecureAuth: true,
- inboundEnabled: true
- });
- await waitForListening(server);
- try {
- await sendViaSmtp({
- host: '127.0.0.1',
- port: server.address().port,
- secure: false,
- username: '',
- password: '',
- helo: 'sender.example.net',
- mailFrom: '',
- recipients: ['postmaster@bounce.example'],
- rawMessage: 'From: MAILER-DAEMON <>\r\nSubject: Delivery status\r\n\r\nBounced.'
- });
- const [message] = listInboundMessages(user.id);
- assert.equal(message.sender, '');
- assert.deepEqual(message.recipients, ['postmaster@bounce.example']);
- } finally {
- await closeServer(server);
- }
- });
- test('SMTP rejects oversized unauthenticated inbound messages without storing them', async () => {
- initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-size-')), 'inbound-secret');
- const user = createUser({ username: 'size-user', email: 'size@example.com', password: 'password123' });
- createDomain(user.id, {
- domain: 'size.example',
- selector: 'mh',
- verificationToken: 'verify',
- dkimPublic: 'public',
- dkimPrivate: 'private',
- senderHost: 'mail.size.example',
- sendingIp: '192.0.2.15',
- spfExtra: '',
- dmarcPolicy: 'none',
- dmarcRua: ''
- });
- createInboundMailbox(user.id, { address: 'support@size.example' });
- const [server] = startSubmissionServer({
- enabled: true,
- listeners: [{ port: 0, protocol: 'smtp' }],
- hostname: 'mx.size.example',
- allowInsecureAuth: true,
- inboundEnabled: true,
- maxMessageBytes: 64
- });
- await waitForListening(server);
- try {
- const transcript = await smtpTranscript(server.address().port, [
- 'EHLO sender.example.net',
- 'MAIL FROM:<alice@example.net>',
- 'RCPT TO:<support@size.example>',
- 'DATA',
- [
- 'Subject: Oversized inbound',
- '',
- 'This body is intentionally longer than the configured inbound message size limit.',
- '.'
- ].join('\r\n')
- ]);
- assert.match(transcript.at(-1), /^552 /);
- assert.equal(listInboundMessages(user.id).length, 0);
- } finally {
- await closeServer(server);
- }
- });
- 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());
- });
- }
- async function smtpTranscript(port, commands) {
- return await new Promise((resolve, reject) => {
- const socket = net.createConnection({ host: '127.0.0.1', port });
- socket.setEncoding('utf8');
- socket.setTimeout(3000);
- const responses = [];
- let buffer = '';
- let index = -1;
- socket.on('data', (chunk) => {
- buffer += chunk;
- let lineEnd;
- while ((lineEnd = buffer.indexOf('\n')) !== -1) {
- const line = buffer.slice(0, lineEnd).replace(/\r$/, '');
- buffer = buffer.slice(lineEnd + 1);
- if (!/^\d{3}[ -]/.test(line)) continue;
- responses.push(line);
- if (/^\d{3} /.test(line)) {
- index += 1;
- if (index >= commands.length) {
- socket.end('QUIT\r\n');
- resolve(responses);
- return;
- }
- Promise.resolve(typeof commands[index] === 'function' ? commands[index]() : commands[index])
- .then((command) => socket.write(`${command}\r\n`))
- .catch(reject);
- }
- }
- });
- socket.once('error', reject);
- socket.once('timeout', () => reject(new Error('SMTP transcript timed out')));
- });
- }
- function startFakeSmtpServer() {
- const commands = [];
- const messages = [];
- const server = net.createServer((socket) => {
- socket.setEncoding('utf8');
- socket.write('220 relay.test ESMTP ready\r\n');
- let buffer = '';
- let dataMode = false;
- let messageLines = [];
- socket.on('data', (chunk) => {
- buffer += chunk;
- let index;
- while ((index = buffer.indexOf('\n')) !== -1) {
- const line = buffer.slice(0, index).replace(/\r$/, '');
- buffer = buffer.slice(index + 1);
- if (dataMode) {
- if (line === '.') {
- dataMode = false;
- messages.push(messageLines.join('\n'));
- messageLines = [];
- socket.write('250 2.0.0 queued as FORWARD123\r\n');
- } else {
- messageLines.push(line);
- }
- continue;
- }
- commands.push(line);
- if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
- else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
- else if (line === 'DATA') {
- dataMode = true;
- socket.write('354 end with dot\r\n');
- } else if (line === 'QUIT') {
- socket.write('221 bye\r\n');
- socket.end();
- }
- }
- });
- });
- return new Promise((resolve, reject) => {
- server.once('error', reject);
- server.listen(0, '127.0.0.1', () => resolve({
- port: server.address().port,
- commands,
- messages,
- close: () => closeServer(server)
- }));
- });
- }
|