| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603 |
- import assert from 'node:assert/strict';
- import { spawn, spawnSync } from 'node:child_process';
- import { mkdtempSync } from 'node:fs';
- import { tmpdir } from 'node:os';
- import path from 'node:path';
- import process from 'node:process';
- import { test } from 'node:test';
- import net from 'node:net';
- test('webhook API requires authentication', async () => {
- const { child, baseUrl } = await startTestServer();
- try {
- const list = await fetch(`${baseUrl}/api/webhooks`);
- assert.equal(list.status, 401);
- assert.equal((await list.json()).error, 'Authentication required.');
- const create = await fetch(`${baseUrl}/api/webhooks`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- name: 'No auth',
- url: 'http://127.0.0.1:9/hook',
- events: ['sent']
- })
- });
- assert.equal(create.status, 401);
- const deliveries = await fetch(`${baseUrl}/api/webhook-deliveries`);
- assert.equal(deliveries.status, 401);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('mailbox webhook API confines email.received to its owned mailbox', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [
- { username: 'mailbox-alice', email: 'mailbox-alice@example.com', password: 'password123', status: 'active' },
- { username: 'mailbox-bob', email: 'mailbox-bob@example.com', password: 'password123', status: 'active' }
- ]);
- const aliceCookie = await login(baseUrl, 'mailbox-alice', 'password123');
- const bobCookie = await login(baseUrl, 'mailbox-bob', 'password123');
- await createSendingDomain(baseUrl, aliceCookie, { domain: 'mailbox-hooks.example' });
- const createMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Cookie: aliceCookie },
- body: JSON.stringify({ address: 'support@mailbox-hooks.example', password: 'mailbox-pass-123' })
- });
- assert.equal(createMailbox.status, 201);
- const mailbox = (await createMailbox.json()).mailbox;
- const createWebhook = await fetch(`${baseUrl}/api/webhooks`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Cookie: aliceCookie },
- body: JSON.stringify({
- name: 'Support arrival',
- url: 'http://127.0.0.1:9/receipt',
- events: ['received'],
- mailboxId: mailbox.id
- })
- });
- assert.equal(createWebhook.status, 201);
- const webhook = (await createWebhook.json()).webhook;
- assert.equal(webhook.mailboxId, mailbox.id);
- assert.equal(webhook.domainId, null);
- assert.deepEqual(webhook.events, ['received']);
- const filtered = await fetch(`${baseUrl}/api/webhooks?mailboxId=${mailbox.id}`, {
- headers: { Cookie: aliceCookie }
- });
- assert.equal(filtered.status, 200);
- assert.equal((await filtered.json()).webhooks[0].id, webhook.id);
- const bobLookup = await fetch(`${baseUrl}/api/webhooks?mailboxId=${mailbox.id}`, {
- headers: { Cookie: bobCookie }
- });
- assert.equal(bobLookup.status, 400);
- const accountReceipt = await fetch(`${baseUrl}/api/webhooks`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Cookie: aliceCookie },
- body: JSON.stringify({
- name: 'Invalid account arrival',
- url: 'http://127.0.0.1:9/invalid-account',
- events: ['received']
- })
- });
- assert.equal(accountReceipt.status, 400);
- const mailboxSend = await fetch(`${baseUrl}/api/webhooks`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Cookie: aliceCookie },
- body: JSON.stringify({
- name: 'Invalid mailbox send',
- url: 'http://127.0.0.1:9/invalid-mailbox',
- events: ['sent'],
- mailboxId: mailbox.id
- })
- });
- assert.equal(mailboxSend.status, 400);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('webhook API isolates users and returns secret only on create/rotate', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [
- { username: 'alice', email: 'alice@example.com', password: 'password123', status: 'active' },
- { username: 'bob', email: 'bob@example.com', password: 'password123', status: 'active' }
- ]);
- const aliceCookie = await login(baseUrl, 'alice', 'password123');
- const bobCookie = await login(baseUrl, 'bob', 'password123');
- const aliceDomain = await createSendingDomain(baseUrl, aliceCookie, { domain: 'alice-hooks.example' });
- const bobDomain = await createSendingDomain(baseUrl, bobCookie, { domain: 'bob-hooks.example' });
- const create = await fetch(`${baseUrl}/api/webhooks`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: aliceCookie
- },
- body: JSON.stringify({
- name: 'Alice primary',
- url: 'http://127.0.0.1:9/alice',
- events: ['sent', 'failed'],
- enabled: true
- })
- });
- assert.equal(create.status, 201);
- const created = await create.json();
- assert.equal(created.webhook.name, 'Alice primary');
- assert.ok(created.webhook.secret);
- assert.match(created.webhook.secret, /^whsec_/);
- assert.equal(created.webhook.secretPrefix, created.webhook.secret.slice(0, 8));
- assert.deepEqual(created.webhook.events, ['sent', 'failed']);
- assert.equal(created.webhook.domainId, null);
- assert.equal(created.webhook.enabled, true);
- const aliceWebhookId = created.webhook.id;
- const firstSecret = created.webhook.secret;
- const bobCreate = await fetch(`${baseUrl}/api/webhooks`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: bobCookie
- },
- body: JSON.stringify({
- name: 'Bob primary',
- url: 'http://127.0.0.1:9/bob',
- events: ['bounced'],
- domainId: bobDomain.id
- })
- });
- assert.equal(bobCreate.status, 201);
- const bobWebhook = (await bobCreate.json()).webhook;
- assert.equal(bobWebhook.domainId, bobDomain.id);
- assert.ok(bobWebhook.secret);
- const aliceList = await fetch(`${baseUrl}/api/webhooks`, {
- headers: { Cookie: aliceCookie }
- });
- assert.equal(aliceList.status, 200);
- const aliceListBody = await aliceList.json();
- assert.equal(aliceListBody.webhooks.length, 1);
- assert.equal(aliceListBody.webhooks[0].id, aliceWebhookId);
- assert.equal('secret' in aliceListBody.webhooks[0], false);
- assert.equal(aliceListBody.webhooks[0].secretPrefix, firstSecret.slice(0, 8));
- const bobSeesAlice = await fetch(`${baseUrl}/api/webhooks`, {
- headers: { Cookie: bobCookie }
- });
- assert.equal(bobSeesAlice.status, 200);
- const bobList = await bobSeesAlice.json();
- assert.equal(bobList.webhooks.length, 1);
- assert.equal(bobList.webhooks[0].id, bobWebhook.id);
- assert.equal(bobList.webhooks[0].name, 'Bob primary');
- assert.equal('secret' in bobList.webhooks[0], false);
- const bobPatchAlice = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: bobCookie
- },
- body: JSON.stringify({ name: 'Hijacked' })
- });
- assert.equal(bobPatchAlice.status, 404);
- const bobDeleteAlice = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}`, {
- method: 'DELETE',
- headers: { Cookie: bobCookie }
- });
- assert.equal(bobDeleteAlice.status, 404);
- const bobRotateAlice = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}/rotate-secret`, {
- method: 'POST',
- headers: { Cookie: bobCookie }
- });
- assert.equal(bobRotateAlice.status, 404);
- const bobTestAlice = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}/test`, {
- method: 'POST',
- headers: { Cookie: bobCookie }
- });
- assert.equal(bobTestAlice.status, 404);
- const stealDomain = await fetch(`${baseUrl}/api/webhooks`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: aliceCookie
- },
- body: JSON.stringify({
- name: 'Steal bob domain',
- url: 'http://127.0.0.1:9/steal',
- events: ['sent'],
- domainId: bobDomain.id
- })
- });
- assert.equal(stealDomain.status, 400);
- assert.match((await stealDomain.json()).error, /域名/);
- const domainScoped = await fetch(`${baseUrl}/api/webhooks`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: aliceCookie
- },
- body: JSON.stringify({
- name: 'Alice domain',
- url: 'http://127.0.0.1:9/alice-domain',
- events: ['failed'],
- domainId: aliceDomain.id
- })
- });
- assert.equal(domainScoped.status, 201);
- assert.equal((await domainScoped.json()).webhook.domainId, aliceDomain.id);
- const filtered = await fetch(`${baseUrl}/api/webhooks?domainId=${aliceDomain.id}`, {
- headers: { Cookie: aliceCookie }
- });
- assert.equal(filtered.status, 200);
- const filteredBody = await filtered.json();
- assert.equal(filteredBody.webhooks.length, 1);
- assert.equal(filteredBody.webhooks[0].name, 'Alice domain');
- const accountOnly = await fetch(`${baseUrl}/api/webhooks?domainId=null`, {
- headers: { Cookie: aliceCookie }
- });
- assert.equal(accountOnly.status, 200);
- const accountOnlyBody = await accountOnly.json();
- assert.equal(accountOnlyBody.webhooks.length, 1);
- assert.equal(accountOnlyBody.webhooks[0].name, 'Alice primary');
- const patch = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: aliceCookie
- },
- body: JSON.stringify({
- name: 'Alice renamed',
- events: ['sent'],
- enabled: false
- })
- });
- assert.equal(patch.status, 200);
- const patched = await patch.json();
- assert.equal(patched.webhook.name, 'Alice renamed');
- assert.deepEqual(patched.webhook.events, ['sent']);
- assert.equal(patched.webhook.enabled, false);
- assert.equal('secret' in patched.webhook, false);
- const rotate = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}/rotate-secret`, {
- method: 'POST',
- headers: { Cookie: aliceCookie }
- });
- assert.equal(rotate.status, 200);
- const rotated = await rotate.json();
- assert.ok(rotated.webhook.secret);
- assert.notEqual(rotated.webhook.secret, firstSecret);
- assert.equal(rotated.webhook.secretPrefix, rotated.webhook.secret.slice(0, 8));
- const afterRotateList = await fetch(`${baseUrl}/api/webhooks?domainId=null`, {
- headers: { Cookie: aliceCookie }
- });
- assert.equal('secret' in (await afterRotateList.json()).webhooks[0], false);
- const invalidEvents = await fetch(`${baseUrl}/api/webhooks`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: aliceCookie
- },
- body: JSON.stringify({
- name: 'Bad events',
- url: 'http://127.0.0.1:9/bad',
- events: ['queued']
- })
- });
- assert.equal(invalidEvents.status, 400);
- const insecureUrl = await fetch(`${baseUrl}/api/webhooks`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: aliceCookie
- },
- body: JSON.stringify({
- name: 'Bad url',
- url: 'http://example.com/hook',
- events: ['sent']
- })
- });
- assert.equal(insecureUrl.status, 400);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('webhook test and replay endpoints work', async () => {
- const { child, baseUrl } = await startTestServer();
- try {
- const cookie = await login(baseUrl, 'admin', 'password123');
- await createSendingDomain(baseUrl, cookie, { domain: 'webhook-test.example' });
- const create = await fetch(`${baseUrl}/api/webhooks`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- name: 'Test endpoint',
- url: 'http://127.0.0.1:9/test',
- events: ['sent', 'bounced']
- })
- });
- assert.equal(create.status, 201);
- const webhook = (await create.json()).webhook;
- const testDelivery = await fetch(`${baseUrl}/api/webhooks/${webhook.id}/test`, {
- method: 'POST',
- headers: { Cookie: cookie }
- });
- assert.equal(testDelivery.status, 202);
- const testBody = await testDelivery.json();
- assert.ok(testBody.delivery);
- assert.equal(testBody.delivery.webhookId, webhook.id);
- assert.equal(testBody.delivery.sendEventId, 0);
- assert.equal(testBody.delivery.eventType, 'sent');
- assert.equal(testBody.delivery.status, 'pending');
- assert.equal(testBody.delivery.attemptCount, 0);
- const payload = JSON.parse(testBody.delivery.payloadJson);
- assert.equal(payload.data.test, true);
- assert.equal(payload.data.message_id, 'mh-test');
- assert.equal(payload.type, 'email.sent');
- const deliveryId = testBody.delivery.id;
- const listDeliveries = await fetch(`${baseUrl}/api/webhook-deliveries?webhookId=${webhook.id}`, {
- headers: { Cookie: cookie }
- });
- assert.equal(listDeliveries.status, 200);
- const listed = await listDeliveries.json();
- assert.equal(listed.deliveries.length, 1);
- assert.equal(listed.deliveries[0].id, deliveryId);
- const replay = await fetch(`${baseUrl}/api/webhook-deliveries/${deliveryId}/replay`, {
- method: 'POST',
- headers: { Cookie: cookie }
- });
- assert.equal(replay.status, 200);
- const replayed = await replay.json();
- assert.equal(replayed.delivery.id, deliveryId);
- assert.equal(replayed.delivery.status, 'pending');
- assert.equal(replayed.delivery.attemptCount, 0);
- assert.equal(JSON.parse(replayed.delivery.payloadJson).id, `whd_${deliveryId}`);
- const retest = await fetch(`${baseUrl}/api/webhooks/${webhook.id}/test`, {
- method: 'POST',
- headers: { Cookie: cookie }
- });
- assert.equal(retest.status, 202);
- const retested = await retest.json();
- assert.equal(retested.delivery.id, deliveryId);
- assert.equal(retested.delivery.status, 'pending');
- const allDeliveries = await fetch(`${baseUrl}/api/webhook-deliveries`, {
- headers: { Cookie: cookie }
- });
- assert.equal(allDeliveries.status, 200);
- assert.ok((await allDeliveries.json()).deliveries.length >= 1);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- test('webhook delivery replay is isolated by user', async () => {
- const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
- try {
- seedUsers(dataDir, sessionSecret, [
- { username: 'carol', email: 'carol@example.com', password: 'password123', status: 'active' },
- { username: 'dave', email: 'dave@example.com', password: 'password123', status: 'active' }
- ]);
- const carolCookie = await login(baseUrl, 'carol', 'password123');
- const daveCookie = await login(baseUrl, 'dave', 'password123');
- const create = await fetch(`${baseUrl}/api/webhooks`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: carolCookie
- },
- body: JSON.stringify({
- name: 'Carol hook',
- url: 'http://127.0.0.1:9/carol',
- events: ['failed']
- })
- });
- assert.equal(create.status, 201);
- const webhook = (await create.json()).webhook;
- const testDelivery = await fetch(`${baseUrl}/api/webhooks/${webhook.id}/test`, {
- method: 'POST',
- headers: { Cookie: carolCookie }
- });
- assert.equal(testDelivery.status, 202);
- const deliveryId = (await testDelivery.json()).delivery.id;
- const daveList = await fetch(`${baseUrl}/api/webhook-deliveries`, {
- headers: { Cookie: daveCookie }
- });
- assert.equal(daveList.status, 200);
- assert.equal((await daveList.json()).deliveries.length, 0);
- const daveReplay = await fetch(`${baseUrl}/api/webhook-deliveries/${deliveryId}/replay`, {
- method: 'POST',
- headers: { Cookie: daveCookie }
- });
- assert.equal(daveReplay.status, 404);
- const deleted = await fetch(`${baseUrl}/api/webhooks/${webhook.id}`, {
- method: 'DELETE',
- headers: { Cookie: carolCookie }
- });
- assert.equal(deleted.status, 200);
- assert.equal((await deleted.json()).deleted, true);
- const missing = await fetch(`${baseUrl}/api/webhooks/${webhook.id}/test`, {
- method: 'POST',
- headers: { Cookie: carolCookie }
- });
- assert.equal(missing.status, 404);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- }
- });
- async function startTestServer() {
- const port = await freePort();
- const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-webhooks-api-'));
- const sessionSecret = 'test-session-secret-webhooks';
- const child = spawn(process.execPath, ['src/server.js'], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- PORT: String(port),
- DATA_DIR: dataDir,
- ADMIN_PASSWORD: 'password123',
- SESSION_SECRET: sessionSecret,
- DNS_AUTO_CHECK_ENABLED: 'false',
- SUBMISSION_ENABLED: 'false',
- IMAP_ENABLED: 'false',
- POP3_ENABLED: 'false',
- WEBHOOK_WORKER_ENABLED: '0',
- WEBHOOK_ALLOW_HTTP_LOCAL: '1',
- DELIVERY_TRACKING_ENABLED: 'false'
- },
- stdio: ['ignore', 'pipe', 'pipe']
- });
- await waitForOutput(child, 'MailHub listening');
- return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
- }
- async function login(baseUrl, username, password) {
- const response = await fetch(`${baseUrl}/api/login`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ username, password })
- });
- assert.equal(response.status, 200);
- const cookie = response.headers.get('set-cookie')?.split(';')[0] || '';
- assert.ok(cookie);
- return cookie;
- }
- function seedUsers(dataDir, sessionSecret, users) {
- const script = `
- import { initDatabase, createUser } from './src/db.js';
- initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
- for (const user of JSON.parse(process.env.SEED_USERS)) {
- createUser(user);
- }
- `;
- const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- DATA_DIR: dataDir,
- SESSION_SECRET: sessionSecret,
- SEED_USERS: JSON.stringify(users)
- },
- encoding: 'utf8'
- });
- assert.equal(result.status, 0, result.stderr || result.stdout);
- }
- async function createSendingDomain(baseUrl, cookie, data = {}) {
- const domain = data.domain || 'send.example';
- const response = await fetch(`${baseUrl}/api/domains`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie
- },
- body: JSON.stringify({
- domain,
- selector: data.selector || 'mh',
- senderHost: data.senderHost || `mail.${domain}`,
- sendingIp: data.sendingIp || '127.0.0.1'
- })
- });
- assert.equal(response.status, 201);
- return (await response.json()).domain;
- }
- function freePort() {
- return new Promise((resolve, reject) => {
- const server = net.createServer();
- server.listen(0, '127.0.0.1', () => {
- const address = server.address();
- server.close(() => {
- if (address && typeof address === 'object') resolve(address.port);
- else reject(new Error('Unable to allocate a test port.'));
- });
- });
- });
- }
- function waitForOutput(child, text) {
- return new Promise((resolve, reject) => {
- const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}`)), 5000);
- const chunks = [];
- const onData = (chunk) => {
- chunks.push(String(chunk));
- if (chunks.join('').includes(text)) {
- clearTimeout(timeout);
- child.stdout.off('data', onData);
- child.stderr.off('data', onData);
- resolve();
- }
- };
- child.stdout.on('data', onData);
- child.stderr.on('data', onData);
- child.once('exit', (code) => {
- clearTimeout(timeout);
- reject(new Error(`Server exited early with code ${code}: ${chunks.join('')}`));
- });
- });
- }
- function waitForExit(child, timeoutMs) {
- if (child.exitCode !== null) return Promise.resolve(true);
- return new Promise((resolve) => {
- const timeout = setTimeout(() => {
- child.off('exit', onExit);
- resolve(false);
- }, timeoutMs);
- const onExit = () => {
- clearTimeout(timeout);
- resolve(true);
- };
- child.once('exit', onExit);
- });
- }
|