import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import net from 'node:net'; import path from 'node:path'; import { test } from 'node:test'; test('listing APIs paginate, filter, isolate users, and expose folder counts', async () => { const fixture = await startTestServer(); try { const seeded = seedListingFixtures(fixture.dataDir, fixture.sessionSecret); const aliceCookie = await login(fixture.baseUrl, 'list-alice', 'password123'); const bobCookie = await login(fixture.baseUrl, 'list-bob', 'password123'); const defaultEvents = await getJson(fixture.baseUrl, '/api/events', aliceCookie); assert.equal(defaultEvents.status, 200); assert.equal(defaultEvents.body.total, 5); assert.equal(defaultEvents.body.page, 1); assert.equal(defaultEvents.body.pageSize, 30); assert.equal(defaultEvents.body.events.length, 5); const firstPage = await getJson(fixture.baseUrl, '/api/events?page=1&pageSize=2', aliceCookie); const secondPage = await getJson(fixture.baseUrl, '/api/events?page=2&pageSize=2', aliceCookie); assert.equal(firstPage.body.total, 5); assert.equal(firstPage.body.events.length, 2); assert.equal(secondPage.body.events.length, 2); assert.equal(new Set([...firstPage.body.events, ...secondPage.body.events].map((event) => event.id)).size, 4); assert.equal((await getJson(fixture.baseUrl, '/api/events?status=failed', aliceCookie)).body.total, 1); assert.equal( (await getJson(fixture.baseUrl, `/api/events?domainId=${seeded.aliceSecondaryDomainId}`, aliceCookie)).body.total, 1 ); assert.equal( (await getJson(fixture.baseUrl, '/api/events?recipient=target%2Bfilter%40example.net', aliceCookie)).body.total, 1 ); const byQueue = await getJson(fixture.baseUrl, '/api/events?q=QUEUE-ALICE-FAILED', aliceCookie); assert.equal(byQueue.body.total, 1); assert.equal(byQueue.body.events[0].status, 'failed'); const byMessageId = await getJson(fixture.baseUrl, `/api/events?q=mh-${seeded.specialEventId}`, aliceCookie); assert.equal(byMessageId.body.total, 1); assert.equal(byMessageId.body.events[0].messageId, `mh-${seeded.specialEventId}`); const byNumericId = await getJson(fixture.baseUrl, `/api/events?q=${seeded.specialEventId}`, aliceCookie); assert.equal(byNumericId.body.total, 1); assert.equal(byNumericId.body.events[0].id, seeded.specialEventId); const bySubject = await getJson(fixture.baseUrl, '/api/events?q=Message%20identifier%20event', aliceCookie); assert.equal(bySubject.body.total, 1); assert.equal(bySubject.body.events[0].id, seeded.specialEventId); assert.equal((await getJson(fixture.baseUrl, '/api/events?from=2000-01-01', aliceCookie)).body.total, 5); assert.equal((await getJson(fixture.baseUrl, '/api/events?to=2000-01-01', aliceCookie)).body.total, 0); const bobEvents = await getJson(fixture.baseUrl, '/api/events?q=QUEUE-BOB-ONLY', bobCookie); assert.equal(bobEvents.body.total, 1); assert.equal((await getJson(fixture.baseUrl, '/api/events?q=QUEUE-BOB-ONLY', aliceCookie)).body.total, 0); const inboxPage = await getJson( fixture.baseUrl, `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&page=1&pageSize=1`, aliceCookie ); assert.equal(inboxPage.status, 200); assert.equal(inboxPage.body.total, 2); assert.equal(inboxPage.body.messages.length, 1); assert.equal(inboxPage.body.page, 1); assert.equal(inboxPage.body.pageSize, 1); const unread = await getJson( fixture.baseUrl, `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&folder=INBOX&read=false`, aliceCookie ); assert.equal(unread.body.total, 1); assert.equal(unread.body.messages[0].read, false); const sent = await getJson( fixture.baseUrl, `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&folder=Sent`, aliceCookie ); assert.equal(sent.body.total, 1); assert.equal(sent.body.messages[0].folder, 'Sent'); const custom = await getJson( fixture.baseUrl, `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&folder=Projects`, aliceCookie ); assert.equal(custom.body.total, 1); assert.equal(custom.body.messages[0].folder, 'Projects'); const byInboundMessageId = await getJson( fixture.baseUrl, '/api/inbound-messages?folder=Projects&q=inbound-project-special', aliceCookie ); assert.equal(byInboundMessageId.body.total, 1); assert.equal(byInboundMessageId.body.messages[0].messageId, ''); const isolatedMessages = await getJson( fixture.baseUrl, `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}`, bobCookie ); assert.equal(isolatedMessages.body.total, 0); const folders = await getJson( fixture.baseUrl, `/api/inbound-mailboxes/${seeded.aliceMailboxId}/folders`, aliceCookie ); assert.equal(folders.status, 200); assert.deepEqual(folderSummary(folders.body.folders, 'INBOX'), { name: 'INBOX', specialUse: null, messageCount: 2, unreadCount: 1 }); assert.deepEqual(folderSummary(folders.body.folders, 'Sent'), { name: 'Sent', specialUse: '\\Sent', messageCount: 1, unreadCount: 0 }); assert.deepEqual(folderSummary(folders.body.folders, 'Projects'), { name: 'Projects', specialUse: null, messageCount: 1, unreadCount: 1 }); assert.ok(folders.body.folders.some((folder) => folder.name === 'Drafts' && folder.messageCount === 0)); const isolatedFolders = await getJson( fixture.baseUrl, `/api/inbound-mailboxes/${seeded.aliceMailboxId}/folders`, bobCookie ); assert.equal(isolatedFolders.status, 404); assert.deepEqual(isolatedFolders.body.folders, []); for (const pathName of [ '/api/events?page=0', '/api/events?page=abc', '/api/events?pageSize=101', '/api/events?domainId=-1', '/api/events?status=sent%20OR%201%3D1', '/api/events?from=not-a-date', '/api/events?from=2026-07-02&to=2026-07-01', '/api/inbound-messages?page=0', '/api/inbound-messages?pageSize=101', '/api/inbound-messages?mailboxId=nope', '/api/inbound-messages?folder=', '/api/inbound-messages?read=1' ]) { const response = await getJson(fixture.baseUrl, pathName, aliceCookie); assert.equal(response.status, 400, pathName); assert.equal(typeof response.body.error, 'string'); } } finally { fixture.child.kill('SIGTERM'); await waitForExit(fixture.child, 1000); } }); test('SMTP API responses keep passwords write-only while internal relay auth still works', async () => { const relayServer = await startFakeSmtpServer(); const fixture = await startTestServer(); try { const cookie = await login(fixture.baseUrl, 'admin', 'password123'); const singularSave = await requestJson(fixture.baseUrl, '/api/smtp-credential', cookie, { method: 'PUT', body: { username: 'legacy-smtp', password: 'legacy-secret' } }); assertSecretSummary(singularSave.body.credential); const singularGet = await getJson(fixture.baseUrl, '/api/smtp-credential', cookie); assertSecretSummary(singularGet.body.credential); const credentialCreate = await requestJson(fixture.baseUrl, '/api/smtp-credentials', cookie, { method: 'POST', body: { username: 'app-smtp', password: 'app-secret' } }); assert.equal(credentialCreate.status, 201); assertSecretSummary(credentialCreate.body.credential); const credentialId = credentialCreate.body.credential.id; const credentialList = await getJson(fixture.baseUrl, '/api/smtp-credentials', cookie); credentialList.body.credentials.forEach(assertSecretSummary); assertSecretSummary((await getJson(fixture.baseUrl, `/api/smtp-credentials/${credentialId}`, cookie)).body.credential); const credentialPatch = await requestJson(fixture.baseUrl, `/api/smtp-credentials/${credentialId}`, cookie, { method: 'PATCH', body: { username: 'app-smtp-renamed' } }); assertSecretSummary(credentialPatch.body.credential); const domain = await requestJson(fixture.baseUrl, '/api/domains', cookie, { method: 'POST', body: { domain: 'write-only-relay.example', selector: 'mh', senderHost: 'mail.write-only-relay.example', sendingIp: '127.0.0.1' } }); assert.equal(domain.status, 201); const relayCreate = await requestJson(fixture.baseUrl, '/api/smtp-relays', cookie, { method: 'POST', body: { name: 'Write-only relay', host: '127.0.0.1', port: relayServer.port, secure: false, username: 'relay-user', password: 'relay-secret', helo: 'mail.write-only-relay.example', isDefault: true } }); assert.equal(relayCreate.status, 201); assertSecretSummary(relayCreate.body.relay); const relayId = relayCreate.body.relay.id; (await getJson(fixture.baseUrl, '/api/smtp-relays', cookie)).body.relays.forEach(assertSecretSummary); assertSecretSummary((await getJson(fixture.baseUrl, `/api/smtp-relays/${relayId}`, cookie)).body.relay); const relayPatch = await requestJson(fixture.baseUrl, `/api/smtp-relays/${relayId}`, cookie, { method: 'PATCH', body: { name: 'Write-only relay renamed' } }); assertSecretSummary(relayPatch.body.relay); const send = await requestJson(fixture.baseUrl, '/api/send', cookie, { method: 'POST', body: { from: 'noreply@write-only-relay.example', to: 'recipient@example.net', subject: 'write-only credential test', text: 'hello', smtpRelayId: relayId } }); assert.equal(send.status, 202); const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN ')); assert.ok(authCommand); assert.equal( Buffer.from(authCommand.slice('AUTH PLAIN '.length), 'base64').toString('utf8'), '\0relay-user\0relay-secret' ); } finally { fixture.child.kill('SIGTERM'); await waitForExit(fixture.child, 1000); await relayServer.close(); } }); test('authentication next paths preserve safe deep links and reject open redirects', async () => { const fixture = await startTestServer(); try { const target = '/domains/42?tab=dns&q=pending'; const anonymous = await fetch(`${fixture.baseUrl}${target}`, { redirect: 'manual' }); assert.equal(anonymous.status, 302); const loginLocation = new URL(anonymous.headers.get('location'), fixture.baseUrl); assert.equal(loginLocation.pathname, '/login'); assert.equal(loginLocation.searchParams.get('next'), target); const successfulLogin = await loginResponse(fixture.baseUrl, 'admin', 'password123', target); assert.equal(successfulLogin.status, 200); const loginBody = await successfulLogin.json(); assert.equal(loginBody.redirectTo, target); const cookie = sessionCookieFrom(successfulLogin); assert.ok(cookie); const authenticatedLogin = await fetch( `${fixture.baseUrl}/login?next=${encodeURIComponent(target)}`, { headers: { Cookie: cookie }, redirect: 'manual' } ); assert.equal(authenticatedLogin.status, 302); assert.equal(authenticatedLogin.headers.get('location'), target); const queryUrlTarget = '/activity?q=https%3A%2F%2Fexample.com%2Fmessage'; const queryUrlLogin = await loginResponse(fixture.baseUrl, 'admin', 'password123', queryUrlTarget); assert.equal(queryUrlLogin.status, 200); assert.equal((await queryUrlLogin.json()).redirectTo, queryUrlTarget); for (const unsafe of [ '//evil.example/path', 'https://evil.example/path', '/\\evil.example/path', '/%2F%2Fevil.example/path', '/%5C%5Cevil.example/path', '/%0Aevil', '/api/events' ]) { const response = await loginResponse(fixture.baseUrl, 'admin', 'password123', unsafe); assert.equal(response.status, 200, unsafe); assert.equal((await response.json()).redirectTo, '/', unsafe); } const invalidAuthenticatedLogin = await fetch( `${fixture.baseUrl}/login?next=${encodeURIComponent('//evil.example/path')}`, { headers: { Cookie: cookie }, redirect: 'manual' } ); assert.equal(invalidAuthenticatedLogin.headers.get('location'), '/'); } finally { fixture.child.kill('SIGTERM'); await waitForExit(fixture.child, 1000); } }); function seedListingFixtures(dataDir, sessionSecret) { const script = ` import { createDomain, createInboundMailbox, createInboundMessage, createUser, initDatabase, logSendEvent, markInboundMessageRead } from './src/db.js'; initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET); const alice = createUser({ username: 'list-alice', email: 'list-alice@example.com', password: 'password123', status: 'active' }); const bob = createUser({ username: 'list-bob', email: 'list-bob@example.com', password: 'password123', status: 'active' }); const domain = (userId, name) => createDomain(userId, { domain: name, selector: 'mh', verificationToken: 'token-' + name, dkimPublic: 'public-' + name, dkimPrivate: 'private-' + name, senderHost: 'mail.' + name, sendingIp: '127.0.0.1', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); const alicePrimary = domain(alice.id, 'listing-alice.example'); const aliceSecondary = domain(alice.id, 'listing-alice-secondary.example'); const bobDomain = domain(bob.id, 'listing-bob.example'); const events = [ { domainId: alicePrimary.id, status: 'sent', recipient: 'one@example.net', subject: 'First event', queueId: 'QUEUE-ALICE-1' }, { domainId: alicePrimary.id, status: 'failed', recipient: 'two@example.net', subject: 'Failed event', queueId: 'QUEUE-ALICE-FAILED' }, { domainId: alicePrimary.id, status: 'bounced', recipient: 'target+filter@example.net', subject: 'Recipient filter event', queueId: 'QUEUE-ALICE-3' }, { domainId: aliceSecondary.id, status: 'sent', recipient: 'four@example.net', subject: 'Secondary domain event', queueId: 'QUEUE-ALICE-4' }, { domainId: alicePrimary.id, status: 'sent', recipient: 'five@example.net', subject: 'Message identifier event', queueId: 'QUEUE-ALICE-5' } ]; let specialEventId = null; for (const event of events) { const eventId = Number(logSendEvent({ userId: alice.id, domainId: event.domainId, sender: 'noreply@listing-alice.example', recipients: [event.recipient], subject: event.subject, status: event.status, detail: event.status + ' detail', queueId: event.queueId })); if (event.subject === 'Message identifier event') specialEventId = eventId; } logSendEvent({ userId: bob.id, domainId: bobDomain.id, sender: 'noreply@listing-bob.example', recipients: ['bob@example.net'], subject: 'Bob only', status: 'sent', detail: 'bob detail', queueId: 'QUEUE-BOB-ONLY' }); const aliceMailbox = createInboundMailbox(alice.id, { address: 'inbox@listing-alice.example', password: 'mailbox-pass-123' }); const bobMailbox = createInboundMailbox(bob.id, { address: 'inbox@listing-bob.example', password: 'mailbox-pass-123' }); const createMessage = (mailbox, values) => createInboundMessage(mailbox, { sender: values.sender || 'sender@example.net', recipients: [mailbox.address], subject: values.subject, messageId: values.messageId, folder: values.folder || 'INBOX', rawMessage: 'Subject: ' + values.subject + '\\r\\n\\r\\n' + values.subject, textBody: values.subject, receivedAt: values.receivedAt }); createMessage(aliceMailbox, { subject: 'Inbox unread', messageId: '', receivedAt: '2026-07-14T10:00:00.000Z' }); const inboxRead = createMessage(aliceMailbox, { subject: 'Inbox read', messageId: '', receivedAt: '2026-07-14T11:00:00.000Z' }); markInboundMessageRead(alice.id, inboxRead.id, true); const sent = createMessage(aliceMailbox, { subject: 'Sent message', messageId: '', folder: 'Sent', receivedAt: '2026-07-14T12:00:00.000Z' }); markInboundMessageRead(alice.id, sent.id, true); createMessage(aliceMailbox, { subject: 'Project message', messageId: '', folder: 'Projects', receivedAt: '2026-07-14T13:00:00.000Z' }); createMessage(bobMailbox, { subject: 'Bob private', messageId: '', receivedAt: '2026-07-14T14:00:00.000Z' }); console.log(JSON.stringify({ aliceSecondaryDomainId: aliceSecondary.id, aliceMailboxId: aliceMailbox.id, bobMailboxId: bobMailbox.id, specialEventId })); `; const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { cwd: process.cwd(), env: { ...process.env, DATA_DIR: dataDir, SESSION_SECRET: sessionSecret }, encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr || result.stdout); return JSON.parse(result.stdout); } async function startTestServer() { const port = await freePort(); const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-listing-api-test-')); const sessionSecret = 'listing-api-session-secret'; const child = spawn(process.execPath, ['src/server.js'], { cwd: process.cwd(), env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, SESSION_SECRET: sessionSecret, ADMIN_USER: 'admin', ADMIN_EMAIL: 'admin@example.com', ADMIN_PASSWORD: 'password123', DNS_AUTO_CHECK_ENABLED: 'false', DELIVERY_TRACKING_ENABLED: 'false', WEBHOOK_WORKER_ENABLED: 'false', SUBMISSION_ENABLED: 'false', IMAP_ENABLED: 'false', POP3_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 loginResponse(baseUrl, username, password); assert.equal(response.status, 200); const cookie = sessionCookieFrom(response); assert.ok(cookie); return cookie; } function loginResponse(baseUrl, username, password, next = '') { return fetch(`${baseUrl}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, next }) }); } function sessionCookieFrom(response) { return response.headers.get('set-cookie')?.split(';')[0] || ''; } async function getJson(baseUrl, pathname, cookie) { const response = await fetch(`${baseUrl}${pathname}`, { headers: { Cookie: cookie }, redirect: 'manual' }); return { status: response.status, body: await response.json() }; } async function requestJson(baseUrl, pathname, cookie, { method, body }) { const response = await fetch(`${baseUrl}${pathname}`, { method, headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify(body) }); return { status: response.status, body: await response.json() }; } function folderSummary(folders, name) { const folder = folders.find((entry) => entry.name === name); assert.ok(folder, `expected folder ${name}`); return folder; } function assertSecretSummary(value) { assert.ok(value); assert.equal(value.passwordSet, true); assert.equal('password' in value, false); assert.equal('passwordHash' in value, false); assert.equal('passwordSecret' in value, false); assert.equal('passwordRecoverable' in value, false); } function freePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.listen(0, '127.0.0.1', () => { const { port } = server.address(); server.close((error) => (error ? reject(error) : resolve(port))); }); server.on('error', reject); }); } function waitForOutput(child, text, timeoutMs = 8000) { return new Promise((resolve, reject) => { let buffer = ''; const timer = setTimeout(() => reject(new Error(`Timed out waiting for: ${text}\n${buffer}`)), timeoutMs); const onData = (chunk) => { buffer += String(chunk); if (!buffer.includes(text)) return; clearTimeout(timer); child.stdout?.off('data', onData); child.stderr?.off('data', onData); resolve(); }; child.stdout?.on('data', onData); child.stderr?.on('data', onData); }); } function waitForExit(child, timeoutMs) { return new Promise((resolve) => { if (child.exitCode != null) return resolve(true); const timer = setTimeout(() => resolve(false), timeoutMs); child.once('exit', () => { clearTimeout(timer); resolve(true); }); }); } function startFakeSmtpServer() { const commands = []; const server = net.createServer((socket) => { socket.setEncoding('utf8'); socket.write('220 relay.test ESMTP ready\r\n'); let buffer = ''; let dataMode = false; 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; socket.write('250 2.0.0 queued as WRITEONLY123\r\n'); } continue; } commands.push(line); if (line.startsWith('EHLO')) socket.write('250-relay.test\r\n250 AUTH PLAIN\r\n'); else if (line.startsWith('AUTH PLAIN')) socket.write('235 2.7.0 authenticated\r\n'); else if (line.startsWith('MAIL FROM')) socket.write('250 2.1.0 ok\r\n'); else if (line.startsWith('RCPT TO')) socket.write('250 2.1.5 ok\r\n'); else if (line === 'DATA') { dataMode = true; socket.write('354 end data\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', () => { server.off('error', reject); resolve({ port: server.address().port, commands, close: () => new Promise((closeResolve) => server.close(closeResolve)) }); }); }); }