import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { mkdtempSync, readdirSync } 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('admin API routes respond once and keep the server alive', async () => { const port = await freePort(); const child = spawn(process.execPath, ['src/server.js'], { cwd: process.cwd(), env: { ...process.env, PORT: String(port), DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-')), ADMIN_PASSWORD: 'password123', SUBMISSION_ENABLED: 'false', IMAP_ENABLED: 'false', POP3_ENABLED: 'false' }, stdio: ['ignore', 'pipe', 'pipe'] }); try { await waitForOutput(child, 'MailHub listening'); const baseUrl = `http://127.0.0.1:${port}`; const login = await fetch(`${baseUrl}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'password123' }) }); assert.equal(login.status, 200); const cookie = login.headers.get('set-cookie')?.split(';')[0] || ''; assert.ok(cookie); const settings = await fetch(`${baseUrl}/api/admin/settings`, { headers: { Cookie: cookie } }); assert.equal(settings.status, 200); const settingsPayload = await settings.json(); assert.equal(settingsPayload.settings.mailHostname, 'mailhub.local'); assert.equal(settingsPayload.settings.systemChecks.ptr.key, 'ptr'); const exited = await waitForExit(child, 300); assert.equal(exited, false); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('built auth assets are served before authentication', async () => { const assetName = readdirSync(path.join(process.cwd(), 'public', 'assets')).find((name) => /\.(js|css)$/.test(name)); assert.ok(assetName, 'expected at least one built frontend asset'); const port = await freePort(); const child = spawn(process.execPath, ['src/server.js'], { cwd: process.cwd(), env: { ...process.env, PORT: String(port), DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-')), ADMIN_PASSWORD: 'password123', SUBMISSION_ENABLED: 'false', IMAP_ENABLED: 'false', POP3_ENABLED: 'false' }, stdio: ['ignore', 'pipe', 'pipe'] }); try { await waitForOutput(child, 'MailHub listening'); const baseUrl = `http://127.0.0.1:${port}`; const login = await fetch(`${baseUrl}/login`); assert.equal(login.status, 200); const asset = await fetch(`${baseUrl}/assets/${assetName}`, { redirect: 'manual' }); assert.equal(asset.status, 200); assert.notEqual(asset.headers.get('location'), '/login'); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('auth pages preserve query messages instead of redirecting them away', async () => { const { child, baseUrl } = await startTestServer(); try { for (const pathname of ['/login?error=hello', '/reset-password?token=abc123']) { const response = await fetch(`${baseUrl}${pathname}`, { redirect: 'manual' }); assert.equal(response.status, 200); assert.equal(response.headers.get('location'), null); assert.match(await response.text(), /auth-root/); } } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('users can manage multiple smtp login credentials', async () => { const { child, baseUrl } = await startTestServer(); try { const cookie = await login(baseUrl, 'admin', 'password123'); const first = await createSmtpCredential(baseUrl, cookie, { username: 'admin-smtp-main', password: 'main-secret' }); const second = await createSmtpCredential(baseUrl, cookie, { username: 'admin-smtp-app', password: 'app-secret' }); assert.equal(first.username, 'admin-smtp-main'); assert.equal(first.passwordSet, true); assert.equal('password' in first, false); assert.equal(second.username, 'admin-smtp-app'); assert.equal(second.passwordSet, true); assert.equal('password' in second, false); const list = await fetch(`${baseUrl}/api/smtp-credentials`, { headers: { Cookie: cookie } }); assert.equal(list.status, 200); const listPayload = await list.json(); assert.deepEqual(listPayload.credentials.map((credential) => credential.username), ['admin-smtp-app', 'admin-smtp-main']); assert.equal(listPayload.credentials[0].passwordSet, true); assert.equal('password' in listPayload.credentials[0], false); const update = await fetch(`${baseUrl}/api/smtp-credentials/${second.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ username: 'admin-smtp-app-renamed' }) }); assert.equal(update.status, 200); const updatedCredential = (await update.json()).credential; assert.equal(updatedCredential.passwordSet, true); assert.equal('password' in updatedCredential, false); const deleted = await fetch(`${baseUrl}/api/smtp-credentials/${first.id}`, { method: 'DELETE', headers: { Cookie: cookie } }); assert.equal(deleted.status, 200); assert.equal((await deleted.json()).deleted, true); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('users can manage inbound mailboxes and read inbound messages', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { const cookie = await login(baseUrl, 'admin', 'password123'); await createSendingDomain(baseUrl, cookie, { domain: 'inbound-api.example' }); const createMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ address: 'Support@inbound-api.example', displayName: 'Support', password: 'mailbox-pass-123', forwardTo: 'archive@example.net', keepForwarded: true }) }); assert.equal(createMailbox.status, 201); const createMailboxBody = await createMailbox.json(); const mailbox = createMailboxBody.mailbox; assert.equal(mailbox.address, 'support@inbound-api.example'); assert.equal(mailbox.displayName, 'Support'); assert.equal(mailbox.passwordSet, true); assert.deepEqual(mailbox.forwardTo, ['archive@example.net']); assert.equal(mailbox.unreadCount, 0); assert.equal(createMailboxBody.clientConfig.username, 'support@inbound-api.example'); assert.equal(createMailboxBody.clientConfig.password, 'mailbox-pass-123'); assert.equal(createMailboxBody.clientConfig.outgoing.authMethod, 'Normal password'); const mailboxes = await fetch(`${baseUrl}/api/inbound-mailboxes`, { headers: { Cookie: cookie } }); assert.equal(mailboxes.status, 200); const mailboxesBody = await mailboxes.json(); assert.deepEqual(mailboxesBody.mailboxes.map((entry) => entry.address), ['support@inbound-api.example']); const messageId = seedInboundMessage(dataDir, sessionSecret, 'support@inbound-api.example'); const messages = await fetch(`${baseUrl}/api/inbound-messages?mailboxId=${mailbox.id}`, { headers: { Cookie: cookie } }); assert.equal(messages.status, 200); const messagesBody = await messages.json(); assert.equal(messagesBody.messages.length, 1); assert.equal(messagesBody.messages[0].id, messageId); assert.equal(messagesBody.messages[0].subject, 'Inbound API message'); assert.equal(messagesBody.messages[0].textBody, undefined); const detail = await fetch(`${baseUrl}/api/inbound-messages/${messageId}`, { headers: { Cookie: cookie } }); assert.equal(detail.status, 200); const detailBody = await detail.json(); assert.equal(detailBody.message.textBody, 'Hello from inbound API.'); assert.equal(detailBody.message.rawMessage.includes('Inbound API message'), true); const markRead = await fetch(`${baseUrl}/api/inbound-messages/${messageId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ read: true }) }); assert.equal(markRead.status, 200); assert.equal((await markRead.json()).message.read, true); const createTarget = await fetch(`${baseUrl}/api/inbound-mailboxes`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ address: 'archive@inbound-api.example', displayName: 'Archive', password: 'archive-pass-123' }) }); assert.equal(createTarget.status, 201); const targetMailbox = (await createTarget.json()).mailbox; const invalidSourceId = await fetch(`${baseUrl}/api/inbound-mailboxes/0`, { method: 'DELETE', headers: { Cookie: cookie } }); assert.equal(invalidSourceId.status, 400); assert.equal((await invalidSourceId.json()).code, 'INBOUND_MAILBOX_DELETE_INVALID_ID'); const unsafeSourceId = await fetch(`${baseUrl}/api/inbound-mailboxes/9007199254740993`, { method: 'DELETE', headers: { Cookie: cookie } }); assert.equal(unsafeSourceId.status, 400); assert.equal((await unsafeSourceId.json()).code, 'INBOUND_MAILBOX_DELETE_INVALID_ID'); const unsafeTargetId = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ targetMailboxId: '9007199254740993', confirmAddress: mailbox.address }) }); assert.equal(unsafeTargetId.status, 400); assert.equal((await unsafeTargetId.json()).code, 'INBOUND_MAILBOX_DELETE_INVALID_ID'); const invalidDeleteBody = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: '{"targetMailboxId":' }); assert.equal(invalidDeleteBody.status, 400); assert.equal((await invalidDeleteBody.json()).code, 'INBOUND_MAILBOX_DELETE_INVALID_BODY'); const emptyDeleteBody = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, { method: 'DELETE', headers: { Cookie: cookie } }); assert.equal(emptyDeleteBody.status, 400); assert.equal((await emptyDeleteBody.json()).code, 'INBOUND_MAILBOX_DELETE_CONFIRMATION_MISMATCH'); const missingConfirmation = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ targetMailboxId: targetMailbox.id }) }); assert.equal(missingConfirmation.status, 400); assert.equal((await missingConfirmation.json()).code, 'INBOUND_MAILBOX_DELETE_CONFIRMATION_MISMATCH'); const sameTarget = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ targetMailboxId: mailbox.id, confirmAddress: mailbox.address }) }); assert.equal(sameTarget.status, 400); assert.equal((await sameTarget.json()).code, 'INBOUND_MAILBOX_DELETE_SAME_TARGET'); const deleteMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ targetMailboxId: targetMailbox.id, confirmAddress: mailbox.address }) }); assert.equal(deleteMailbox.status, 200); const deleted = await deleteMailbox.json(); assert.equal(deleted.deleted, true); assert.deepEqual(deleted.deletedMailbox, { id: mailbox.id, address: mailbox.address }); assert.equal(deleted.targetMailbox.id, targetMailbox.id); assert.equal(deleted.migratedMessageCount, 1); const movedDetail = await fetch(`${baseUrl}/api/inbound-messages/${messageId}`, { headers: { Cookie: cookie } }); assert.equal(movedDetail.status, 200); assert.equal((await movedDetail.json()).message.mailboxId, targetMailbox.id); const deletedSource = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ displayName: 'Should not update' }) }); assert.equal(deletedSource.status, 404); const createEmpty = await fetch(`${baseUrl}/api/inbound-mailboxes`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ address: 'empty@inbound-api.example', password: 'empty-mailbox-pass-123' }) }); assert.equal(createEmpty.status, 201); const emptyMailbox = (await createEmpty.json()).mailbox; const deleteEmpty = await fetch(`${baseUrl}/api/inbound-mailboxes/${emptyMailbox.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ confirmAddress: emptyMailbox.address }) }); assert.equal(deleteEmpty.status, 200); const emptyResult = await deleteEmpty.json(); assert.equal(emptyResult.targetMailbox, null); assert.equal(emptyResult.migratedMessageCount, 0); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('shared domains are available for mailbox creation but not domain management', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { const ownerCookie = await login(baseUrl, 'admin', 'password123'); const sharedDomain = await createSendingDomain(baseUrl, ownerCookie, { domain: 'shared-mailbox.example' }); const share = await fetch(`${baseUrl}/api/domains/${sharedDomain.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: ownerCookie }, body: JSON.stringify({ mailboxSignupEnabled: true }) }); assert.equal(share.status, 200); assert.equal((await share.json()).domain.mailboxSignupEnabled, true); seedUsers(dataDir, sessionSecret, [ { username: 'shared-user', email: 'shared-user@example.com', password: 'password123', status: 'active' } ]); const userCookie = await login(baseUrl, 'shared-user', 'password123'); const manageableDomains = await fetch(`${baseUrl}/api/domains`, { headers: { Cookie: userCookie } }); assert.equal(manageableDomains.status, 200); assert.deepEqual((await manageableDomains.json()).domains, []); const mailboxDomains = await fetch(`${baseUrl}/api/inbound-mailbox-domains`, { headers: { Cookie: userCookie } }); assert.equal(mailboxDomains.status, 200); const mailboxDomainRows = (await mailboxDomains.json()).domains; assert.deepEqual(mailboxDomainRows.map((domain) => domain.domain), ['shared-mailbox.example']); assert.equal('dkimPublic' in mailboxDomainRows[0], false); const ownerMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: ownerCookie }, body: JSON.stringify({ address: 'owner@shared-mailbox.example', password: 'mailbox-pass-123', aliases: ['claimed'] }) }); assert.equal(ownerMailbox.status, 201); const addressConflict = await fetch(`${baseUrl}/api/inbound-mailboxes`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: userCookie }, body: JSON.stringify({ address: 'claimed@shared-mailbox.example', password: 'mailbox-pass-123' }) }); assert.equal(addressConflict.status, 409); assert.match((await addressConflict.json()).error, /claimed@shared-mailbox\.example 已被其他邮箱占用/); const createMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: userCookie }, body: JSON.stringify({ address: 'user@shared-mailbox.example', password: 'mailbox-pass-123', aliases: ['user-alias'] }) }); assert.equal(createMailbox.status, 201); const createdMailbox = (await createMailbox.json()).mailbox; assert.equal(createdMailbox.address, 'user@shared-mailbox.example'); const aliasConflict = await fetch(`${baseUrl}/api/inbound-mailboxes/${createdMailbox.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: userCookie }, body: JSON.stringify({ aliases: ['owner'] }) }); assert.equal(aliasConflict.status, 409); assert.match((await aliasConflict.json()).error, /owner@shared-mailbox\.example 已被其他邮箱占用/); const blockedDelete = await fetch(`${baseUrl}/api/domains/${sharedDomain.id}`, { method: 'DELETE', headers: { Cookie: ownerCookie } }); assert.equal(blockedDelete.status, 409); assert.match((await blockedDelete.json()).error, /仍有关联收信邮箱/); const blockedPatch = await fetch(`${baseUrl}/api/domains/${sharedDomain.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: userCookie }, body: JSON.stringify({ catchAllAddress: 'user@shared-mailbox.example' }) }); assert.equal(blockedPatch.status, 404); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('scoped API tokens create persistent and temporary mailboxes', async () => { const { child, baseUrl } = await startTestServer(); try { const cookie = await login(baseUrl, 'admin', 'password123'); await createSendingDomain(baseUrl, cookie, { domain: 'mailbox-token-api.example' }); const sendOnlyResponse = await fetch(`${baseUrl}/api/api-tokens`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ name: 'send only', scopes: ['send'] }) }); assert.equal(sendOnlyResponse.status, 201); const sendOnly = (await sendOnlyResponse.json()).token; const denied = await fetch(`${baseUrl}/api/mailboxes`, { method: 'POST', headers: { Authorization: `Bearer ${sendOnly.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'temporary', domain: 'mailbox-token-api.example', expiresInMinutes: 60 }) }); assert.equal(denied.status, 403); const tokenResponse = await fetch(`${baseUrl}/api/api-tokens`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ name: 'mailboxes', scopes: ['mailboxes:read', 'mailboxes:write'] }) }); assert.equal(tokenResponse.status, 201); const mailboxToken = (await tokenResponse.json()).token; const temporary = await fetch(`${baseUrl}/api/mailboxes`, { method: 'POST', headers: { Authorization: `Bearer ${mailboxToken.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'temporary', domain: 'mailbox-token-api.example', expiresInMinutes: 60 }) }); assert.equal(temporary.status, 201); const temporaryBody = await temporary.json(); assert.match(temporaryBody.mailbox.address, /^tmp-[a-f0-9]+@mailbox-token-api\.example$/); assert.equal(temporaryBody.mailbox.temporary, true); assert.ok(temporaryBody.mailbox.expiresAt); assert.equal(temporaryBody.password.length >= 8, true); assert.equal(temporaryBody.clientConfig.incoming.protocol, 'IMAP'); const permanent = await fetch(`${baseUrl}/api/mailboxes`, { method: 'POST', headers: { Authorization: `Bearer ${mailboxToken.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'permanent', address: 'support@mailbox-token-api.example', password: 'mailbox-pass-123' }) }); assert.equal(permanent.status, 201); const permanentBody = await permanent.json(); assert.equal(permanentBody.mailbox.temporary, false); assert.equal(permanentBody.mailbox.expiresAt, null); assert.equal(permanentBody.clientConfig.outgoing.protocol, 'SMTP'); const listed = await fetch(`${baseUrl}/api/mailboxes`, { headers: { Authorization: `Bearer ${mailboxToken.token}` } }); assert.equal(listed.status, 200); assert.equal((await listed.json()).mailboxes.length, 2); const sendDenied = await fetch(`${baseUrl}/api/send`, { method: 'POST', headers: { Authorization: `Bearer ${mailboxToken.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ from: 'noreply@mailbox-token-api.example', to: 'user@example.com', text: 'blocked' }) }); assert.equal(sendDenied.status, 403); const revoke = await fetch(`${baseUrl}/api/api-tokens/${mailboxToken.id}`, { method: 'DELETE', headers: { Cookie: cookie } }); assert.equal(revoke.status, 200); assert.equal((await revoke.json()).token.status, 'revoked'); const rejectedAfterRevoke = await fetch(`${baseUrl}/api/mailboxes`, { headers: { Authorization: `Bearer ${mailboxToken.token}` } }); assert.equal(rejectedAfterRevoke.status, 401); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('users can manage outbound smtp relays with recoverable passwords and send through a selected relay', async () => { const relayServer = await startFakeSmtpServer(); const { child, baseUrl } = await startTestServer(); try { const cookie = await login(baseUrl, 'admin', 'password123'); const domainResponse = await fetch(`${baseUrl}/api/domains`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ domain: 'relay.example', selector: 'mh', senderHost: 'mail.relay.example', sendingIp: '127.0.0.1' }) }); assert.equal(domainResponse.status, 201); const createRelay = await fetch(`${baseUrl}/api/smtp-relays`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ name: 'Primary outbound', host: '127.0.0.1', port: relayServer.port, secure: false, username: 'relay-user', password: 'relay-password', helo: 'helo.relay.example', isDefault: true }) }); assert.equal(createRelay.status, 201); const created = await createRelay.json(); assert.equal(created.relay.passwordSet, true); assert.equal('password' in created.relay, false); const list = await fetch(`${baseUrl}/api/smtp-relays`, { headers: { Cookie: cookie } }); assert.equal(list.status, 200); const listed = await list.json(); assert.equal(listed.relays.length, 1); assert.equal('password' in listed.relays[0], false); const detail = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } }); assert.equal(detail.status, 200); const detailBody = await detail.json(); assert.equal(detailBody.relay.passwordSet, true); assert.equal('password' in detailBody.relay, false); assert.equal('passwordSecret' in detailBody.relay, false); const missingPatch = await fetch(`${baseUrl}/api/smtp-relays/999999`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ name: 'Missing relay', host: '127.0.0.1', port: relayServer.port, secure: false, username: 'missing-user', password: 'missing-password' }) }); assert.equal(missingPatch.status, 404); const updateWithoutPassword = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ name: 'Primary outbound renamed', host: '127.0.0.1', port: relayServer.port, secure: false, username: 'relay-user' }) }); assert.equal(updateWithoutPassword.status, 200); const updatedRelay = (await updateWithoutPassword.json()).relay; assert.equal(updatedRelay.isDefault, true); assert.equal(updatedRelay.passwordSet, true); assert.equal('password' in updatedRelay, false); const detailAfterPatch = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } }); const detailAfterPatchBody = await detailAfterPatch.json(); assert.equal(detailAfterPatchBody.relay.passwordSet, true); assert.equal('password' in detailAfterPatchBody.relay, false); const invalidRelaySend = await fetch(`${baseUrl}/api/send`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ from: 'noreply@relay.example', to: 'user@example.com', subject: 'Invalid relay', text: 'hello', smtpRelayId: 999999 }) }); assert.equal(invalidRelaySend.status, 400); const send = await fetch(`${baseUrl}/api/send`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ from: 'noreply@relay.example', to: 'user@example.com', subject: 'Relay send', text: 'hello', smtpRelayId: created.relay.id }) }); assert.equal(send.status, 202); assert.equal((await send.json()).smtpRelayId, created.relay.id); await waitForCondition(() => relayServer.messages.length === 1); const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN ')); assert.ok(authCommand); assert.equal(Buffer.from(authCommand.replace('AUTH PLAIN ', ''), 'base64').toString('utf8'), '\0relay-user\0relay-password'); const events = await fetch(`${baseUrl}/api/events`, { headers: { Cookie: cookie } }); assert.equal(events.status, 200); const eventsBody = await events.json(); assert.equal(eventsBody.events[0].smtpRelayId, created.relay.id); const eventDetail = await fetch(`${baseUrl}/api/events/${eventsBody.events[0].id}`, { headers: { Cookie: cookie } }); assert.equal(eventDetail.status, 200); const eventDetailBody = await eventDetail.json(); assert.equal(eventDetailBody.event.id, eventsBody.events[0].id); assert.equal(eventDetailBody.event.smtpRelayId, created.relay.id); assert.equal(Array.isArray(eventDetailBody.event.webhookDeliveries), true); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); await relayServer.close(); } }); test('smtp relay selection prefers request relay then domain relay then user default relay', async () => { const requestRelayServer = await startFakeSmtpServer(); const domainRelayServer = await startFakeSmtpServer(); const defaultRelayServer = await startFakeSmtpServer(); const { child, baseUrl } = await startTestServer(); try { const cookie = await login(baseUrl, 'admin', 'password123'); const settings = await fetch(`${baseUrl}/api/admin/settings`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ appBaseUrl: baseUrl, engagementTrackingEnabled: true }) }); assert.equal(settings.status, 200); const defaultRelay = await createSmtpRelay(baseUrl, cookie, { name: 'Default relay', host: '127.0.0.1', port: defaultRelayServer.port, username: 'default-user', password: 'default-password', isDefault: true }); const domainRelay = await createSmtpRelay(baseUrl, cookie, { name: 'Domain relay', host: '127.0.0.1', port: domainRelayServer.port, username: 'domain-user', password: 'domain-password' }); const requestRelay = await createSmtpRelay(baseUrl, cookie, { name: 'Request relay', host: '127.0.0.1', port: requestRelayServer.port, username: 'request-user', password: 'request-password' }); const domain = await createSendingDomain(baseUrl, cookie, { domain: 'relay-order.example', smtpRelayId: domainRelay.id }); assert.equal(domain.smtpRelayId, domainRelay.id); const domainSend = await sendApiMail(baseUrl, cookie, { from: 'noreply@relay-order.example', to: 'domain@example.com', subject: 'Domain relay' }); assert.equal(domainSend.smtpRelayId, domainRelay.id); await waitForCondition(() => domainRelayServer.messages.length === 1); assertRelayAuth(domainRelayServer, 'domain-user', 'domain-password'); const requestSend = await sendApiMail(baseUrl, cookie, { from: 'noreply@relay-order.example', to: 'request@example.com', subject: 'Request relay', smtpRelayId: requestRelay.id }); assert.equal(requestSend.smtpRelayId, requestRelay.id); await waitForCondition(() => requestRelayServer.messages.length === 1); assertRelayAuth(requestRelayServer, 'request-user', 'request-password'); const testSend = await fetch(`${baseUrl}/api/domains/${domain.id}/test-send`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ to: 'test-send@example.com', subject: 'Selected relay test send', text: 'Open the HTML version to verify tracking.', html: '

MailHub tracking test.

Tracked link', smtpRelayId: requestRelay.id }) }); assert.equal(testSend.status, 202); const testSendBody = await testSend.json(); assert.equal(testSendBody.smtpRelayId, requestRelay.id); assert.deepEqual(testSendBody.tracking, { enabled: true, opens: true, clicks: true, messageLevel: false }); await waitForCondition(() => requestRelayServer.messages.length === 2); const testHtml = decodeHtmlPart(requestRelayServer.messages[1]); assert.match(testHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/o/[A-Za-z0-9_-]+\\.gif`)); assert.match(testHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/c/[A-Za-z0-9_-]+`)); assert.equal(testHtml.includes('https://example.net/tracked'), false); const invalidTestSend = await fetch(`${baseUrl}/api/domains/${domain.id}/test-send`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ to: 'invalid-test-send@example.com', smtpRelayId: 999999 }) }); assert.equal(invalidTestSend.status, 400); const defaultDomain = await createSendingDomain(baseUrl, cookie, { domain: 'default-relay.example' }); assert.equal(defaultDomain.smtpRelayId, null); const defaultSend = await sendApiMail(baseUrl, cookie, { from: 'noreply@default-relay.example', to: 'default@example.com', subject: 'Default relay' }); assert.equal(defaultSend.smtpRelayId, defaultRelay.id); await waitForCondition(() => defaultRelayServer.messages.length === 1); assertRelayAuth(defaultRelayServer, 'default-user', 'default-password'); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); await requestRelayServer.close(); await domainRelayServer.close(); await defaultRelayServer.close(); } }); test('admin users can list audit logs', async () => { const { child, baseUrl } = await startTestServer(); try { const cookie = await login(baseUrl, 'admin', 'password123'); const response = await fetch(`${baseUrl}/api/admin/audit-logs`, { headers: { Cookie: cookie } }); assert.equal(response.status, 200); assert.deepEqual(await response.json(), { logs: [] }); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('admin users can list resource inventory', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedUsers(dataDir, sessionSecret, [{ username: 'alice', email: 'alice@example.com', password: 'password123', status: 'active' }]); const adminCookie = await login(baseUrl, 'admin', 'password123'); const userCookie = await login(baseUrl, 'alice', 'password123'); const forbidden = await fetch(`${baseUrl}/api/admin/resources`, { headers: { Cookie: userCookie } }); assert.equal(forbidden.status, 403); const response = await fetch(`${baseUrl}/api/admin/resources`, { headers: { Cookie: adminCookie } }); assert.equal(response.status, 200); const body = await response.json(); assert.ok(Array.isArray(body.inventory.users)); assert.ok(Array.isArray(body.inventory.warnings)); assert.ok(body.inventory.users.some((entry) => entry.user.username === 'alice')); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('admin users can transfer individual resources', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { const seeded = seedTransferResources(dataDir, sessionSecret); const adminCookie = await login(baseUrl, 'admin', 'password123'); const aliceCookie = await login(baseUrl, 'alice', 'password123'); const forbidden = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: aliceCookie }, body: JSON.stringify({ targetUserId: seeded.bobId }) }); assert.equal(forbidden.status, 403); const domain = await fetch(`${baseUrl}/api/admin/resources/domains/${seeded.domainId}/transfer`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: adminCookie }, body: JSON.stringify({ targetUserId: seeded.bobId, dnsCredentialMode: 'with_dns_credential' }) }); assert.equal(domain.status, 200); const domainBody = await domain.json(); assert.equal(domainBody.domain.userId, seeded.bobId); assert.equal(domainBody.domain.dnsCredentialId, seeded.credentialId); const dns = await fetch(`${baseUrl}/api/admin/resources/dns-credentials/${seeded.standaloneCredentialId}/transfer`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: adminCookie }, body: JSON.stringify({ targetUserId: seeded.bobId }) }); assert.equal(dns.status, 200); assert.equal((await dns.json()).credential.userId, seeded.bobId); const tokens = await fetch(`${baseUrl}/api/admin/resources/api-tokens/transfer`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: adminCookie }, body: JSON.stringify({ targetUserId: seeded.bobId, tokenIds: [seeded.apiTokenId] }) }); assert.equal(tokens.status, 200); const tokensBody = await tokens.json(); assert.equal(tokensBody.tokens.length, 1); assert.equal(tokensBody.tokens[0].userId, seeded.bobId); const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${seeded.bobId}`, { headers: { Cookie: adminCookie } }); const actions = (await audit.json()).logs.map((entry) => entry.action); assert.ok(actions.includes('admin.transfer_domain')); assert.ok(actions.includes('admin.transfer_dns_credential')); assert.ok(actions.includes('admin.transfer_api_tokens')); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('admin users can preview and execute user merge', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { const seeded = seedMergeResources(dataDir, sessionSecret); const adminCookie = await login(baseUrl, 'admin', 'password123'); const previewResponse = await fetch(`${baseUrl}/api/admin/migrations/user-merge/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: adminCookie }, body: JSON.stringify({ sourceUserId: seeded.sourceId, targetUserId: seeded.targetId }) }); assert.equal(previewResponse.status, 200); const preview = (await previewResponse.json()).preview; assert.equal(preview.confirmationText, 'MERGE mergesource INTO mergetarget'); assert.equal(preview.counts.domains, 1); const invalid = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: adminCookie }, body: JSON.stringify({ sourceUserId: seeded.sourceId, targetUserId: seeded.targetId, confirmation: 'wrong' }) }); assert.equal(invalid.status, 400); const execute = await fetch(`${baseUrl}/api/admin/migrations/user-merge/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: adminCookie }, body: JSON.stringify({ sourceUserId: seeded.sourceId, targetUserId: seeded.targetId, confirmation: preview.confirmationText }) }); assert.equal(execute.status, 200); const result = (await execute.json()).result; assert.equal(result.counts.domains, 1); assert.equal(result.sourceUser.status, 'disabled'); const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.user_merge`, { headers: { Cookie: adminCookie } }); assert.equal(audit.status, 200); assert.equal((await audit.json()).logs[0].targetUserId, seeded.targetId); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('admin users can manage system email settings without exposing password', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedUsers(dataDir, sessionSecret, [{ username: 'alice', email: 'alice@example.com', password: 'password123', status: 'active' }]); const adminCookie = await login(baseUrl, 'admin', 'password123'); const userCookie = await login(baseUrl, 'alice', 'password123'); const forbidden = await fetch(`${baseUrl}/api/admin/system-email`, { headers: { Cookie: userCookie } }); assert.equal(forbidden.status, 403); const empty = await fetch(`${baseUrl}/api/admin/system-email`, { headers: { Cookie: adminCookie } }); assert.equal(empty.status, 200); assert.equal((await empty.json()).settings.passwordSet, false); const saved = await fetch(`${baseUrl}/api/admin/system-email`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: adminCookie }, body: JSON.stringify({ host: 'smtp.example.com', port: 587, secure: false, username: 'mailer@example.com', password: 'smtp-password-123', helo: 'mail.example.com', fromEmail: 'notify@example.com', fromName: 'MailHub Notify', testRecipient: 'admin@example.com' }) }); assert.equal(saved.status, 200); const savedBody = await saved.json(); assert.equal(savedBody.settings.host, 'smtp.example.com'); assert.equal(savedBody.settings.port, 587); assert.equal(savedBody.settings.secure, false); assert.equal(savedBody.settings.passwordSet, true); assert.equal('password' in savedBody.settings, false); assert.equal(JSON.stringify(savedBody).includes('smtp-password-123'), false); const preserved = await fetch(`${baseUrl}/api/admin/system-email`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: adminCookie }, body: JSON.stringify({ host: 'smtp2.example.com', password: '' }) }); assert.equal(preserved.status, 200); const preservedBody = await preserved.json(); assert.equal(preservedBody.settings.host, 'smtp2.example.com'); assert.equal(preservedBody.settings.passwordSet, true); assert.equal(JSON.stringify(preservedBody).includes('smtp-password-123'), false); const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.update_system_email`, { headers: { Cookie: adminCookie } }); assert.equal(audit.status, 200); const [entry] = (await audit.json()).logs; assert.equal(entry.action, 'admin.update_system_email'); assert.equal(entry.targetType, 'system_email'); assert.equal(entry.summary.host, 'smtp2.example.com'); assert.equal(entry.summary.password, undefined); assert.equal(entry.summary.passwordSet, true); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('registration and verification resend use configured system email', async () => { const smtp = await startFakeSmtpServer(); const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedUsers(dataDir, sessionSecret, [ { username: 'publicpending', email: 'publicpending@example.com', password: 'password123', status: 'pending_email' }, { username: 'adminpending', email: 'adminpending@example.com', password: 'password123', status: 'pending_email' } ]); const adminCookie = await login(baseUrl, 'admin', 'password123'); await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port); const register = await fetch(`${baseUrl}/api/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'mailuser', email: 'mailuser@example.com', password: 'password123' }) }); assert.equal(register.status, 201); const registerBody = await register.json(); assert.equal(registerBody.user.status, 'pending_email'); assert.equal(registerBody.verificationEmailSent, true); assert.equal(registerBody.message, '注册成功,验证邮件已发送,请先验证邮箱。'); assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'mailuser', 'email_verification'), 1); const publicResend = await fetch(`${baseUrl}/api/auth/resend-verification`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'publicpending@example.com' }) }); assert.equal(publicResend.status, 202); const publicResendBody = await publicResend.json(); assert.equal(publicResendBody.message, '如果账号需要验证,我们会发送验证邮件。'); assert.equal('verificationEmailSent' in publicResendBody, false); assert.equal('result' in publicResendBody, false); await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'publicpending', 'email_verification') === 1); const usersResponse = await fetch(`${baseUrl}/api/admin/users`, { headers: { Cookie: adminCookie } }); const adminPending = (await usersResponse.json()).users.find((user) => user.username === 'adminpending'); assert.ok(adminPending); const adminResend = await fetch(`${baseUrl}/api/admin/users/${adminPending.id}/resend-verification`, { method: 'POST', headers: { Cookie: adminCookie } }); assert.equal(adminResend.status, 202); assert.equal((await adminResend.json()).verificationEmailSent, true); assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'adminpending', 'email_verification'), 1); assert.ok(smtp.commands.some((command) => command === 'MAIL FROM:')); assert.ok(smtp.commands.some((command) => command === 'RCPT TO:')); assert.ok(smtp.commands.some((command) => command === 'RCPT TO:')); assert.ok(smtp.commands.some((command) => command === 'RCPT TO:')); assert.equal(JSON.stringify(smtp.commands).includes('smtp-password-123'), false); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); await smtp.close(); } }); test('public verification resend is generic and does not create tokens without mail config', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedUsers(dataDir, sessionSecret, [{ username: 'pendingnomail', email: 'pendingnomail@example.com', password: 'password123', status: 'pending_email' }]); const response = await fetch(`${baseUrl}/api/auth/resend-verification`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'pendingnomail@example.com' }) }); assert.equal(response.status, 202); assert.deepEqual(await response.json(), { message: '如果账号需要验证,我们会发送验证邮件。' }); assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'pendingnomail', 'email_verification'), 0); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('registration reports pending email when system email is not configured', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { const register = await fetch(`${baseUrl}/api/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'nomailuser', email: 'nomailuser@example.com', password: 'password123' }) }); assert.equal(register.status, 201); const body = await register.json(); assert.equal(body.user.status, 'pending_email'); assert.equal(body.verificationEmailSent, false); assert.match(body.message, /验证邮件暂未发送/); assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'nomailuser', 'email_verification'), 1); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('admin users can send system email test messages', async () => { const smtp = await startFakeSmtpServer(); const { child, baseUrl } = await startTestServer(); try { const adminCookie = await login(baseUrl, 'admin', 'password123'); await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port); const response = await fetch(`${baseUrl}/api/admin/system-email/test`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: adminCookie }, body: JSON.stringify({ to: 'operator@example.com' }) }); assert.equal(response.status, 202); const body = await response.json(); assert.equal(body.result.ok, true); assert.equal(body.result.queueId, 'SYS123'); assert.equal(JSON.stringify(body).includes('smtp-password-123'), false); assert.ok(smtp.commands.some((command) => command === 'RCPT TO:')); const audit = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.test_system_email`, { headers: { Cookie: adminCookie } }); assert.equal(audit.status, 200); const [entry] = (await audit.json()).logs; assert.equal(entry.targetType, 'system_email'); assert.equal(entry.summary.to, 'operator@example.com'); assert.equal(entry.summary.ok, true); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); await smtp.close(); } }); test('public forgot password is generic and sends reset email when configured', async () => { const smtp = await startFakeSmtpServer({ responseDelayMs: 700 }); const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedUsers(dataDir, sessionSecret, [{ username: 'resetme', email: 'resetme@example.com', password: 'password123', status: 'active' }]); const adminCookie = await login(baseUrl, 'admin', 'password123'); await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port); const startedAt = Date.now(); const existing = await fetch(`${baseUrl}/api/auth/forgot-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'resetme@example.com' }) }); const elapsedMs = Date.now() - startedAt; assert.equal(existing.status, 202); assert.equal(elapsedMs < 500, true); assert.deepEqual(await existing.json(), { message: '如果邮箱存在,我们会发送密码重置邮件。' }); await waitForCondition(() => countAccountTokensForUser(dataDir, sessionSecret, 'resetme', 'password_reset') === 1); const missing = await fetch(`${baseUrl}/api/auth/forgot-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'missing@example.com' }) }); assert.equal(missing.status, 202); assert.deepEqual(await missing.json(), { message: '如果邮箱存在,我们会发送密码重置邮件。' }); await waitForCondition(() => smtp.commands.some((command) => command === 'RCPT TO:')); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); await smtp.close(); } }); test('public reset password consumes token and updates password', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedUsers(dataDir, sessionSecret, [{ username: 'tokenreset', email: 'tokenreset@example.com', password: 'password123', status: 'active' }]); const token = createPasswordResetToken(dataDir, sessionSecret, 'tokenreset'); const response = await fetch(`${baseUrl}/api/auth/reset-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password: 'new-password-123' }) }); assert.equal(response.status, 200); assert.deepEqual(await response.json(), { message: '密码已重置,请使用新密码登录。' }); const oldLogin = await loginResponse(baseUrl, 'tokenreset', 'password123'); assert.equal(oldLogin.status, 401); const newLogin = await loginResponse(baseUrl, 'tokenreset', 'new-password-123'); assert.equal(newLogin.status, 200); const reused = await fetch(`${baseUrl}/api/auth/reset-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password: 'another-password-123' }) }); assert.equal(reused.status, 400); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('admin users can trigger password reset email and set temporary password', async () => { const smtp = await startFakeSmtpServer(); const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedUsers(dataDir, sessionSecret, [ { username: 'targetuser', email: 'targetuser@example.com', password: 'password123', status: 'active' }, { username: 'member2', email: 'member2@example.com', password: 'password123', status: 'active' } ]); const adminCookie = await login(baseUrl, 'admin', 'password123'); const memberCookie = await login(baseUrl, 'member2', 'password123'); await saveSystemEmailSettings(baseUrl, adminCookie, smtp.port); const usersResponse = await fetch(`${baseUrl}/api/admin/users`, { headers: { Cookie: adminCookie } }); const target = (await usersResponse.json()).users.find((user) => user.username === 'targetuser'); assert.ok(target); const forbiddenReset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, { method: 'POST', headers: { Cookie: memberCookie } }); assert.equal(forbiddenReset.status, 403); const reset = await fetch(`${baseUrl}/api/admin/users/${target.id}/password-reset`, { method: 'POST', headers: { Cookie: adminCookie } }); assert.equal(reset.status, 202); assert.equal((await reset.json()).result.ok, true); assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 1); assert.ok(smtp.commands.some((command) => command === 'RCPT TO:')); const forbiddenTemporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: memberCookie }, body: JSON.stringify({ password: 'temporary-123' }) }); assert.equal(forbiddenTemporary.status, 403); const temporary = await fetch(`${baseUrl}/api/admin/users/${target.id}/temporary-password`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: adminCookie }, body: JSON.stringify({ password: 'temporary-123' }) }); assert.equal(temporary.status, 200); assert.equal((await temporary.json()).user.id, target.id); assert.equal(countUnusedAccountTokensForUser(dataDir, sessionSecret, 'targetuser', 'password_reset'), 0); const oldLogin = await loginResponse(baseUrl, 'targetuser', 'password123'); assert.equal(oldLogin.status, 401); const tempLogin = await loginResponse(baseUrl, 'targetuser', 'temporary-123'); assert.equal(tempLogin.status, 200); const audit = await fetch(`${baseUrl}/api/admin/audit-logs?targetUserId=${target.id}`, { headers: { Cookie: adminCookie } }); assert.equal(audit.status, 200); const logs = (await audit.json()).logs; assert.ok(logs.some((entry) => entry.action === 'admin.password_reset')); const temporaryLog = logs.find((entry) => entry.action === 'admin.temporary_password'); assert.ok(temporaryLog); assert.equal(temporaryLog.summary.username, 'targetuser'); assert.equal(temporaryLog.summary.password, undefined); assert.equal(JSON.stringify(temporaryLog).includes('temporary-123'), false); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); await smtp.close(); } }); test('non-admin users cannot list audit logs', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedUsers(dataDir, sessionSecret, [{ username: 'alice', email: 'alice@example.com', password: 'password123', status: 'active' }]); const cookie = await login(baseUrl, 'alice', 'password123'); const response = await fetch(`${baseUrl}/api/admin/audit-logs`, { headers: { Cookie: cookie } }); assert.equal(response.status, 403); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('admin user patch rejects invalid status with a bad request', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedUsers(dataDir, sessionSecret, [{ username: 'badstatus', email: 'badstatus@example.com', password: 'password123', status: 'active' }]); const cookie = await login(baseUrl, 'admin', 'password123'); const usersResponse = await fetch(`${baseUrl}/api/admin/users`, { headers: { Cookie: cookie } }); assert.equal(usersResponse.status, 200); const usersBody = await usersResponse.json(); const target = usersBody.users.find((user) => user.username === 'badstatus'); assert.ok(target); const response = await fetch(`${baseUrl}/api/admin/users/${target.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ status: 'archived' }) }); assert.equal(response.status, 400); assert.equal((await response.json()).error, '用户状态不正确。'); const shortPassword = await fetch(`${baseUrl}/api/admin/users/${target.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ password: 'short' }) }); assert.equal(shortPassword.status, 400); assert.equal((await shortPassword.json()).error, '密码至少需要 8 位。'); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('self registration creates a pending email user and verification token without a session', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { const register = await fetch(`${baseUrl}/api/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'newuser', email: 'newuser@example.com', password: 'password123' }) }); assert.equal(register.status, 201); assert.equal(sessionCookieFrom(register), ''); const text = await register.text(); assert.doesNotMatch(text, /token/i); const body = JSON.parse(text); assert.equal('token' in body, false); assert.equal('token' in body.user, false); assert.equal('tokenHash' in body.user, false); assert.equal(body.user.status, 'pending_email'); assert.match(body.message, /验证邮箱/); assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'newuser', 'email_verification'), 1); const login = await loginResponse(baseUrl, 'newuser', 'password123'); assert.equal(login.status, 403); assert.equal(sessionCookieFrom(login), ''); assert.equal((await login.json()).error, '请先验证邮箱。'); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('email verification activates users by default and redirects browsers with an exact success message', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { const created = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, { username: 'verifyme', email: 'verifyme@example.com', password: 'password123', status: 'pending_email' }); const missing = await fetch(`${baseUrl}/api/auth/verify-email`); assert.equal(missing.status, 400); assert.equal(sessionCookieFrom(missing), ''); const invalid = await fetch(`${baseUrl}/api/auth/verify-email?token=not-a-real-token`); assert.equal(invalid.status, 400); assert.equal(sessionCookieFrom(invalid), ''); const htmlInvalid = await fetch(`${baseUrl}/api/auth/verify-email?token=not-a-real-token`, { redirect: 'manual', headers: { Accept: 'text/html,application/xhtml+xml' } }); assert.equal(htmlInvalid.status, 303); assert.match(htmlInvalid.headers.get('location') || '', /^\/login\?error=/); assert.equal(sessionCookieFrom(htmlInvalid), ''); const browserCreated = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, { username: 'verifybrowser', email: 'verifybrowser@example.com', password: 'password123', status: 'pending_email' }); const browserResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(browserCreated.token)}`, { redirect: 'manual', headers: { Accept: 'text/html,application/xhtml+xml' } }); assert.equal(browserResponse.status, 303); assert.equal( browserResponse.headers.get('location'), `/login?message=${encodeURIComponent('邮箱验证成功,现在可以登录。')}` ); assert.equal(sessionCookieFrom(browserResponse), ''); const browserLogin = await loginResponse(baseUrl, 'verifybrowser', 'password123'); assert.equal(browserLogin.status, 200); assert.ok(sessionCookieFrom(browserLogin)); const response = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`); assert.equal(response.status, 200); assert.equal(sessionCookieFrom(response), ''); const body = await response.json(); assert.equal(body.user.id, created.user.id); assert.equal(body.user.status, 'active'); assert.equal(body.message, '邮箱验证成功,现在可以登录。'); const reused = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`); assert.equal(reused.status, 400); assert.equal(sessionCookieFrom(reused), ''); const verifiedLogin = await loginResponse(baseUrl, 'verifyme', 'password123'); assert.equal(verifiedLogin.status, 200); assert.ok(sessionCookieFrom(verifiedLogin)); const alreadyPendingReview = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, { username: 'alreadyreview', email: 'alreadyreview@example.com', password: 'password123', status: 'pending_review' }); const pendingReviewResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(alreadyPendingReview.token)}`); assert.equal(pendingReviewResponse.status, 200); const pendingReviewBody = await pendingReviewResponse.json(); assert.equal(pendingReviewBody.user.status, 'pending_review'); assert.equal(pendingReviewBody.message, '邮箱验证成功,请等待管理员审核。'); const disabled = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, { username: 'disabledverify', email: 'disabledverify@example.com', password: 'password123', status: 'disabled' }); const disabledResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(disabled.token)}`); assert.equal(disabledResponse.status, 400); assert.equal(countUnusedAccountTokensForUser(dataDir, sessionSecret, 'disabledverify', 'email_verification'), 1); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('admin registration approval setting persists and keeps verified users pending review', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedUsers(dataDir, sessionSecret, [ { username: 'settingsmember', email: 'settingsmember@example.com', password: 'password123', status: 'active' } ]); const adminCookie = await login(baseUrl, 'admin', 'password123'); const memberCookie = await login(baseUrl, 'settingsmember', 'password123'); const defaultsResponse = await fetch(`${baseUrl}/api/admin/settings`, { headers: { Cookie: adminCookie } }); assert.equal(defaultsResponse.status, 200); assert.equal((await defaultsResponse.json()).settings.registrationRequiresApproval, false); const forbiddenRead = await fetch(`${baseUrl}/api/admin/settings`, { headers: { Cookie: memberCookie } }); assert.equal(forbiddenRead.status, 403); const forbidden = await fetch(`${baseUrl}/api/admin/settings`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: memberCookie }, body: JSON.stringify({ registrationRequiresApproval: true }) }); assert.equal(forbidden.status, 403); const unchangedResponse = await fetch(`${baseUrl}/api/admin/settings`, { headers: { Cookie: adminCookie } }); assert.equal((await unchangedResponse.json()).settings.registrationRequiresApproval, false); const enabledResponse = await fetch(`${baseUrl}/api/admin/settings`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: adminCookie }, body: JSON.stringify({ registrationRequiresApproval: true }) }); assert.equal(enabledResponse.status, 200); assert.equal((await enabledResponse.json()).settings.registrationRequiresApproval, true); const persistedResponse = await fetch(`${baseUrl}/api/admin/settings`, { headers: { Cookie: adminCookie } }); assert.equal(persistedResponse.status, 200); assert.equal((await persistedResponse.json()).settings.registrationRequiresApproval, true); const register = await fetch(`${baseUrl}/api/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'reviewregistration', email: 'reviewregistration@example.com', password: 'password123' }) }); assert.equal(register.status, 201); const registerBody = await register.json(); assert.equal(registerBody.user.status, 'pending_email'); assert.doesNotMatch(registerBody.message, /管理员审核|即可登录/); const created = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, { username: 'reviewrequired', email: 'reviewrequired@example.com', password: 'password123', status: 'pending_email' }); const response = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`); assert.equal(response.status, 200); assert.equal(sessionCookieFrom(response), ''); const body = await response.json(); assert.equal(body.user.status, 'pending_review'); assert.equal(body.message, '邮箱验证成功,请等待管理员审核。'); const browserCreated = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, { username: 'reviewbrowser', email: 'reviewbrowser@example.com', password: 'password123', status: 'pending_email' }); const browserResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(browserCreated.token)}`, { redirect: 'manual', headers: { Accept: 'text/html,application/xhtml+xml' } }); assert.equal(browserResponse.status, 303); assert.equal( browserResponse.headers.get('location'), `/login?message=${encodeURIComponent('邮箱验证成功,请等待管理员审核。')}` ); assert.equal(sessionCookieFrom(browserResponse), ''); const alreadyActive = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, { username: 'alreadyactive', email: 'alreadyactive@example.com', password: 'password123', status: 'active' }); const activeResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(alreadyActive.token)}`); assert.equal(activeResponse.status, 200); const activeBody = await activeResponse.json(); assert.equal(activeBody.user.status, 'active'); assert.equal(activeBody.message, '邮箱验证成功,现在可以登录。'); await assertLoginDeniedByStatus(baseUrl, 'reviewrequired', '账号正在等待管理员审核。'); await assertLoginDeniedByStatus(baseUrl, 'reviewbrowser', '账号正在等待管理员审核。'); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('admin users can approve pending review users with an audit log', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedUsers(dataDir, sessionSecret, [ { username: 'reviewme', email: 'reviewme@example.com', password: 'password123', status: 'pending_review' }, { username: 'emailonly', email: 'emailonly@example.com', password: 'password123', status: 'pending_email' }, { username: 'disabledreview', email: 'disabledreview@example.com', password: 'password123', status: 'disabled' }, { username: 'member', email: 'member@example.com', password: 'password123', status: 'active' } ]); const adminCookie = await login(baseUrl, 'admin', 'password123'); const usersResponse = await fetch(`${baseUrl}/api/admin/users`, { headers: { Cookie: adminCookie } }); assert.equal(usersResponse.status, 200); const users = (await usersResponse.json()).users; const target = users.find((user) => user.username === 'reviewme'); const pendingEmail = users.find((user) => user.username === 'emailonly'); const disabled = users.find((user) => user.username === 'disabledreview'); assert.ok(target); assert.ok(pendingEmail); assert.ok(disabled); const memberCookie = await login(baseUrl, 'member', 'password123'); const nonAdmin = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, { method: 'POST', headers: { Cookie: memberCookie } }); assert.equal(nonAdmin.status, 403); const missing = await fetch(`${baseUrl}/api/admin/users/999999/approve`, { method: 'POST', headers: { Cookie: adminCookie } }); assert.equal(missing.status, 404); const pendingEmailResponse = await fetch(`${baseUrl}/api/admin/users/${pendingEmail.id}/approve`, { method: 'POST', headers: { Cookie: adminCookie } }); assert.equal(pendingEmailResponse.status, 400); assert.match((await pendingEmailResponse.json()).error, /验证邮箱|等待审核/); const disabledResponse = await fetch(`${baseUrl}/api/admin/users/${disabled.id}/approve`, { method: 'POST', headers: { Cookie: adminCookie } }); assert.equal(disabledResponse.status, 400); assert.match((await disabledResponse.json()).error, /等待审核|只能审批/); const response = await fetch(`${baseUrl}/api/admin/users/${target.id}/approve`, { method: 'POST', headers: { Cookie: adminCookie } }); assert.equal(response.status, 200); const body = await response.json(); assert.equal(body.user.id, target.id); assert.equal(body.user.status, 'active'); const approvedCookie = await login(baseUrl, 'reviewme', 'password123'); assert.ok(approvedCookie); const auditResponse = await fetch(`${baseUrl}/api/admin/audit-logs?action=admin.approve_user`, { headers: { Cookie: adminCookie } }); assert.equal(auditResponse.status, 200); const [entry] = (await auditResponse.json()).logs; assert.equal(entry.action, 'admin.approve_user'); assert.equal(entry.targetType, 'user'); assert.equal(entry.targetId, String(target.id)); assert.equal(entry.targetUserId, target.id); assert.equal(entry.summary.username, 'reviewme'); assert.equal(entry.summary.status, 'active'); assert.equal(entry.summary.password, undefined); assert.equal(entry.summary.token, undefined); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('login returns account status restrictions only after password verification', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedUsers(dataDir, sessionSecret, [ { username: 'pendingemail', email: 'pendingemail@example.com', password: 'password123', status: 'pending_email' }, { username: 'pendingreview', email: 'pendingreview@example.com', password: 'password123', status: 'pending_review' }, { username: 'disableduser', email: 'disableduser@example.com', password: 'password123', status: 'disabled' }, { username: 'activeuser', email: 'activeuser@example.com', password: 'password123', status: 'active' } ]); await assertLoginDeniedByStatus(baseUrl, 'pendingemail', '请先验证邮箱。'); await assertLoginDeniedByStatus(baseUrl, 'pendingreview', '账号正在等待管理员审核。'); await assertLoginDeniedByStatus(baseUrl, 'disableduser', '账号已被禁用。'); const active = await loginResponse(baseUrl, 'activeuser', 'password123'); assert.equal(active.status, 200); assert.ok(sessionCookieFrom(active)); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('admin audit log actor filter rejects non-decimal user ids', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedAuditLogs(dataDir, sessionSecret); const cookie = await login(baseUrl, 'admin', 'password123'); assert.deepEqual( await auditLogActions(baseUrl, cookie, 'actorUserId=1'), ['audit.actor-one'] ); assert.deepEqual( await auditLogActions(baseUrl, cookie, 'actorUserId=1e2'), ['audit.actor-one-hundred', 'audit.actor-one'] ); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); test('admin audit log date filter ignores invalid dates', async () => { const { child, baseUrl, dataDir, sessionSecret } = await startTestServer(); try { seedAuditLogs(dataDir, sessionSecret); const cookie = await login(baseUrl, 'admin', 'password123'); assert.deepEqual(await auditLogActions(baseUrl, cookie, 'from=2999-01-01T00%3A00%3A00.000Z'), []); assert.deepEqual( await auditLogActions(baseUrl, cookie, 'from=2026-02-31'), ['audit.actor-one-hundred', 'audit.actor-one'] ); } finally { child.kill('SIGTERM'); await waitForExit(child, 1000); } }); async function startTestServer() { const port = await freePort(); const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-server-test-')); const sessionSecret = 'test-session-secret'; 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, REGISTRATION_REQUIRES_APPROVAL: 'false', DNS_AUTO_CHECK_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) { return fetch(`${baseUrl}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); } async function assertLoginDeniedByStatus(baseUrl, username, message) { const wrongPassword = await loginResponse(baseUrl, username, 'wrong-password'); assert.equal(wrongPassword.status, 401); assert.equal((await wrongPassword.json()).error, '账号或密码不正确。'); assert.equal(sessionCookieFrom(wrongPassword), ''); const correctPassword = await loginResponse(baseUrl, username, 'password123'); assert.equal(correctPassword.status, 403); assert.equal((await correctPassword.json()).error, message); assert.equal(sessionCookieFrom(correctPassword), ''); } function sessionCookieFrom(response) { return response.headers.get('set-cookie')?.split(';')[0] || ''; } 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); } function seedInboundMessage(dataDir, sessionSecret, address) { const script = ` import { createInboundMessage, getInboundMailboxByAddress, initDatabase } from './src/db.js'; initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET); const mailbox = getInboundMailboxByAddress(process.env.INBOUND_ADDRESS); const message = createInboundMessage(mailbox, { sender: 'alice@example.net', recipients: [process.env.INBOUND_ADDRESS], subject: 'Inbound API message', messageId: '', rawMessage: [ 'From: Alice ', 'To: Support <' + process.env.INBOUND_ADDRESS + '>', 'Subject: Inbound API message', '', 'Hello from inbound API.' ].join('\\r\\n'), textBody: 'Hello from inbound API.' }); console.log(String(message.id)); `; const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { cwd: process.cwd(), env: { ...process.env, DATA_DIR: dataDir, SESSION_SECRET: sessionSecret, INBOUND_ADDRESS: address }, encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr || result.stdout); return Number(result.stdout.trim()); } function seedTransferResources(dataDir, sessionSecret) { const script = ` import { initDatabase, createApiToken, createDomain, createUser, saveDnsCredential } from './src/db.js'; initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET); const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123', status: 'active' }); const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123', status: 'active' }); const credential = saveDnsCredential(alice.id, { name: 'Alice DNS', provider: 'cloudflare', zoneName: 'alice.example', credentials: { apiToken: 'secret-token' } }); const standaloneCredential = saveDnsCredential(alice.id, { name: 'Standalone DNS', provider: 'cloudflare', zoneName: 'standalone.example', credentials: { apiToken: 'standalone-secret-token' } }); const domain = createDomain(alice.id, { dnsCredentialId: credential.id, domain: 'alice.example', selector: 'mh202607', verificationToken: 'token', dkimPublic: 'public', dkimPrivate: 'private', senderHost: 'mail.alice.example', sendingIp: '127.0.0.1', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); const apiToken = createApiToken(alice.id, 'primary'); console.log(JSON.stringify({ aliceId: alice.id, bobId: bob.id, domainId: domain.id, credentialId: credential.id, standaloneCredentialId: standaloneCredential.id, apiTokenId: apiToken.id })); `; 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); } function seedMergeResources(dataDir, sessionSecret) { const script = ` import { initDatabase, createApiToken, createDomain, createUser, logSendEvent, saveDnsCredential } from './src/db.js'; initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET); const source = createUser({ username: 'mergesource', email: 'mergesource@example.com', password: 'password123', status: 'active' }); const target = createUser({ username: 'mergetarget', email: 'mergetarget@example.com', password: 'password123', status: 'active' }); const credential = saveDnsCredential(source.id, { name: 'Merge DNS', provider: 'cloudflare', zoneName: 'merge.example', credentials: { apiToken: 'merge-secret-token' } }); const domain = createDomain(source.id, { dnsCredentialId: credential.id, domain: 'merge.example', selector: 'mh202607', verificationToken: 'token', dkimPublic: 'public', dkimPrivate: 'private', senderHost: 'mail.merge.example', sendingIp: '127.0.0.1', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); createApiToken(source.id, 'primary'); logSendEvent({ userId: source.id, domainId: domain.id, sender: 'noreply@merge.example', recipients: ['a@example.com'], subject: 'Queued', status: 'queued' }); console.log(JSON.stringify({ sourceId: source.id, targetId: target.id })); `; 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); } function createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, user) { const script = ` import { initDatabase, createUser, createAccountToken } from './src/db.js'; initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET); const user = createUser(JSON.parse(process.env.SEED_USER)); const token = createAccountToken(user.id, 'email_verification', { ttlMinutes: 24 * 60 }); console.log(JSON.stringify({ user, token: token.token })); `; const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { cwd: process.cwd(), env: { ...process.env, DATA_DIR: dataDir, SESSION_SECRET: sessionSecret, SEED_USER: JSON.stringify(user) }, encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr || result.stdout); return JSON.parse(result.stdout); } function createPasswordResetToken(dataDir, sessionSecret, username) { const script = ` import { initDatabase, getUserByLogin, createAccountToken } from './src/db.js'; initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET); const user = getUserByLogin(process.env.TOKEN_USERNAME); const token = createAccountToken(user.id, 'password_reset', { ttlMinutes: 60 }); console.log(token.token); `; const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { cwd: process.cwd(), env: { ...process.env, DATA_DIR: dataDir, SESSION_SECRET: sessionSecret, TOKEN_USERNAME: username }, encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr || result.stdout); return result.stdout.trim(); } function countAccountTokensForUser(dataDir, sessionSecret, username, purpose) { return countAccountTokens(dataDir, sessionSecret, username, purpose, false); } function countUnusedAccountTokensForUser(dataDir, sessionSecret, username, purpose) { return countAccountTokens(dataDir, sessionSecret, username, purpose, true); } function countAccountTokens(dataDir, sessionSecret, username, purpose, unusedOnly) { const script = ` import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { initDatabase, getUserByLogin } from './src/db.js'; initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET); const user = getUserByLogin(process.env.TOKEN_USERNAME); const database = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite')); database.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;'); const unusedFilter = process.env.TOKEN_UNUSED_ONLY === 'true' ? ' AND used_at IS NULL' : ''; const row = user ? database .prepare('SELECT COUNT(*) AS count FROM account_tokens WHERE user_id = ? AND purpose = ?' + unusedFilter) .get(user.id, process.env.TOKEN_PURPOSE) : { count: 0 }; console.log(String(row.count)); `; const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { cwd: process.cwd(), env: { ...process.env, DATA_DIR: dataDir, SESSION_SECRET: sessionSecret, TOKEN_USERNAME: username, TOKEN_PURPOSE: purpose, TOKEN_UNUSED_ONLY: String(unusedOnly) }, encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr || result.stdout); return Number(result.stdout.trim()); } function seedAuditLogs(dataDir, sessionSecret) { const script = ` import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { initDatabase, logAudit } from './src/db.js'; initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET); const actorOneId = logAudit({ actorUserId: 1, action: 'audit.actor-one', targetType: 'system', summary: { label: 'actor-one' } }); const actorOneHundredId = logAudit({ actorUserId: 100, action: 'audit.actor-one-hundred', targetType: 'system', summary: { label: 'actor-one-hundred' } }); const db = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite')); db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;'); const update = db.prepare('UPDATE audit_logs SET created_at = ? WHERE id = ?'); update.run('2026-02-01T00:00:00.000Z', actorOneId); update.run('2026-02-02T00:00:00.000Z', actorOneHundredId); `; 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); } async function auditLogActions(baseUrl, cookie, query) { const response = await fetch(`${baseUrl}/api/admin/audit-logs?${query}`, { headers: { Cookie: cookie } }); assert.equal(response.status, 200); const body = await response.json(); return body.logs.map((log) => log.action); } async function saveSystemEmailSettings(baseUrl, cookie, smtpPort) { const response = await fetch(`${baseUrl}/api/admin/system-email`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ host: '127.0.0.1', port: smtpPort, secure: false, username: 'mailer@example.com', password: 'smtp-password-123', helo: 'mail.example.com', fromEmail: 'notify@example.com', fromName: 'MailHub Notify', testRecipient: 'admin@example.com' }) }); assert.equal(response.status, 200); } 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', smtpRelayId: data.smtpRelayId }) }); assert.equal(response.status, 201); return (await response.json()).domain; } async function createSmtpRelay(baseUrl, cookie, data = {}) { const response = await fetch(`${baseUrl}/api/smtp-relays`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ name: data.name || 'Relay', host: data.host || '127.0.0.1', port: data.port, secure: data.secure || false, username: data.username || '', password: data.password || '', helo: data.helo || '', isDefault: data.isDefault || false }) }); assert.equal(response.status, 201); return (await response.json()).relay; } async function createSmtpCredential(baseUrl, cookie, data = {}) { const response = await fetch(`${baseUrl}/api/smtp-credentials`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify(data) }); assert.equal(response.status, 201); return (await response.json()).credential; } async function sendApiMail(baseUrl, cookie, data) { const response = await fetch(`${baseUrl}/api/send`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ from: data.from, to: data.to, subject: data.subject, text: data.text || 'hello', smtpRelayId: data.smtpRelayId }) }); assert.equal(response.status, 202); return response.json(); } function assertRelayAuth(relayServer, username, password) { const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN ')); assert.ok(authCommand); assert.equal(Buffer.from(authCommand.replace('AUTH PLAIN ', ''), 'base64').toString('utf8'), `\0${username}\0${password}`); } function decodeHtmlPart(rawMessage) { const match = rawMessage.match(/Content-Type: text\/html[^]*?\n\n([A-Za-z0-9+/=\n]+?)(?:\n--|$)/i); assert.ok(match, 'expected an HTML MIME part'); return Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8'); } function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function startFakeSmtpServer({ responseDelayMs = 0 } = {}) { const commands = []; const messages = []; const server = net.createServer((socket) => { socket.setEncoding('utf8'); writeSmtpResponse(socket, '220 relay.test ESMTP ready', responseDelayMs); 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 = []; writeSmtpResponse(socket, '250 2.0.0 queued as SYS123', responseDelayMs); } else { messageLines.push(line); } continue; } commands.push(line); if (line.startsWith('EHLO')) { writeSmtpResponse(socket, '250-relay.test\r\n250 AUTH PLAIN', responseDelayMs); } else if (line.startsWith('AUTH PLAIN')) { writeSmtpResponse(socket, '235 2.7.0 authentication successful', responseDelayMs); } else if (line.startsWith('MAIL FROM')) { writeSmtpResponse(socket, '250 2.1.0 sender ok', responseDelayMs); } else if (line.startsWith('RCPT TO')) { writeSmtpResponse(socket, '250 2.1.5 recipient ok', responseDelayMs); } else if (line === 'DATA') { dataMode = true; writeSmtpResponse(socket, '354 end with dot', responseDelayMs); } else if (line === 'QUIT') { writeSmtpResponse(socket, '221 bye', responseDelayMs); 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, messages, close: () => new Promise((closeResolve) => server.close(closeResolve)) }); }); }); } function writeSmtpResponse(socket, response, delayMs) { const write = () => socket.write(`${response}\r\n`); if (delayMs > 0) setTimeout(write, delayMs); else write(); } async function waitForCondition(predicate, { timeoutMs = 7000, intervalMs = 50 } = {}) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { if (predicate()) return; await new Promise((resolve) => setTimeout(resolve, intervalMs)); } assert.fail('Timed out waiting for condition.'); } 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); }); }