import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { mkdtempSync } from 'node:fs'; import net from 'node:net'; import { tmpdir } from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { test } from 'node:test'; test('mailbox grants constrain admin, session, token, and send API access', async (t) => { const smtp = await startFakeSmtpServer(); const readerSmtp = await startFakeSmtpServer(); const fixture = await startTestServer(smtp.port); try { const seeded = seedMailboxAccessFixtures(fixture.dataDir, fixture.sessionSecret, readerSmtp.port); const adminCookie = await login(fixture.baseUrl, 'admin', 'password123'); const ownerCookie = await login(fixture.baseUrl, 'access-owner', 'password123'); const readerCookie = await login(fixture.baseUrl, 'access-reader', 'password123'); const viewerCookie = await login(fixture.baseUrl, 'access-viewer', 'password123'); await t.test('only administrators can list and replace mailbox grants', async () => { const ordinaryList = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', { cookie: readerCookie }); assert.equal(ordinaryList.status, 403); const ordinaryReplace = await requestJson( fixture.baseUrl, `/api/admin/inbound-mailboxes/${seeded.sharedMailboxId}/access`, { method: 'PUT', cookie: readerCookie, body: { grants: [] } } ); assert.equal(ordinaryReplace.status, 403); const malformed = await fetch( `${fixture.baseUrl}/api/admin/inbound-mailboxes/${seeded.sharedMailboxId}/access`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Cookie: adminCookie }, body: '{"grants":' } ); assert.equal(malformed.status, 400); assert.equal(typeof (await malformed.json()).error, 'string'); const initial = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', { cookie: adminCookie }); assert.equal(initial.status, 200); const initialShared = findAdminMailbox(initial.body.mailboxes, seeded.sharedMailboxId); assert.equal(initialShared.owner.id, seeded.ownerUserId); assert.deepEqual(initialShared.grants, []); const replaced = await replaceGrants( fixture.baseUrl, adminCookie, seeded.sharedMailboxId, [ { userId: seeded.readerUserId, permissions: ['receive', 'send'] }, { userId: seeded.viewerUserId, permissions: ['view'] } ] ); assert.equal(replaced.mailbox.id, seeded.sharedMailboxId); assert.equal(replaced.owner.id, seeded.ownerUserId); assert.deepEqual(grantPermissions(replaced, seeded.readerUserId), { view: true, receive: true, send: true }); assert.deepEqual(grantPermissions(replaced, seeded.viewerUserId), { view: true, receive: false, send: false }); const listed = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', { cookie: adminCookie }); const listedShared = findAdminMailbox(listed.body.mailboxes, seeded.sharedMailboxId); assert.deepEqual(grantPermissions(listedShared, seeded.readerUserId), { view: true, receive: true, send: true }); }); await t.test('owned, effective, and all mailbox scopes stay distinct', async () => { assert.deepEqual( await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=owned', ownerCookie), ['other@access-owner.example', 'shared@access-owner.example'] ); assert.deepEqual( await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=owned', readerCookie), ['own@access-reader.example'] ); assert.deepEqual( await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=effective', readerCookie), ['own@access-reader.example', 'shared@access-owner.example'] ); assert.deepEqual( await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=effective', viewerCookie), ['shared@access-owner.example'] ); const ordinaryAll = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=all', { cookie: readerCookie }); assert.equal(ordinaryAll.status, 403); const adminAll = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=all', { cookie: adminCookie }); assert.equal(adminAll.status, 200); assert.deepEqual( adminAll.body.mailboxes.map((mailbox) => mailbox.address).sort(), [ 'other@access-owner.example', 'own@access-reader.example', 'shared@access-owner.example' ] ); const invalid = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=unknown', { cookie: adminCookie }); assert.equal(invalid.status, 400); }); await t.test('receive grants allow message reads while view-only grants do not', async () => { const list = await requestJson( fixture.baseUrl, `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}&q=Shared%20grant`, { cookie: readerCookie } ); assert.equal(list.status, 200); assert.equal(list.body.total, 1); assert.deepEqual(list.body.messages.map((message) => message.id), [seeded.sharedMessageId]); const detail = await requestJson( fixture.baseUrl, `/api/inbound-messages/${seeded.sharedMessageId}`, { cookie: readerCookie } ); assert.equal(detail.status, 200); assert.equal(detail.body.message.subject, 'Shared grant message'); const marked = await requestJson( fixture.baseUrl, `/api/inbound-messages/${seeded.sharedMessageId}`, { method: 'PATCH', cookie: readerCookie, body: { read: true } } ); assert.equal(marked.status, 200); assert.equal(marked.body.message.read, true); const viewerList = await requestJson( fixture.baseUrl, `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`, { cookie: viewerCookie } ); assert.equal(viewerList.status, 200); assert.equal(viewerList.body.total, 0); const viewerDetail = await requestJson( fixture.baseUrl, `/api/inbound-messages/${seeded.sharedMessageId}`, { cookie: viewerCookie } ); assert.equal(viewerDetail.status, 404); const viewerMark = await requestJson( fixture.baseUrl, `/api/inbound-messages/${seeded.sharedMessageId}`, { method: 'PATCH', cookie: viewerCookie, body: { read: false } } ); assert.equal(viewerMark.status, 404); }); await t.test('Bearer owner scope cannot expand into grants and selected scope is revoked immediately', async () => { const ownerToken = await createToken(fixture.baseUrl, readerCookie, { name: 'grant owner scope', scopes: ['mailboxes:read', 'messages:read'], mailboxAccess: 'owner' }); const ownerMessages = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', { bearer: ownerToken.token }); assert.equal(ownerMessages.status, 200); assert.deepEqual(ownerMessages.body.messages.map((message) => message.id), [seeded.readerMessageId]); const ownerMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', { bearer: ownerToken.token }); assert.deepEqual(ownerMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.readerMailboxId]); const selectedToken = await createToken(fixture.baseUrl, readerCookie, { name: 'grant selected scope', scopes: ['mailboxes:read', 'messages:read'], mailboxAccess: 'selected', mailboxIds: [seeded.sharedMailboxId] }); const selectedMessages = await requestJson( fixture.baseUrl, `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`, { bearer: selectedToken.token } ); assert.equal(selectedMessages.status, 200); assert.deepEqual(selectedMessages.body.messages.map((message) => message.id), [seeded.sharedMessageId]); const selectedDetail = await requestJson( fixture.baseUrl, `/api/inbound-messages/${seeded.sharedMessageId}`, { bearer: selectedToken.token } ); assert.equal(selectedDetail.status, 200); await replaceGrants( fixture.baseUrl, adminCookie, seeded.sharedMailboxId, [{ userId: seeded.viewerUserId, permissions: ['view'] }] ); const afterRevoke = await requestJson( fixture.baseUrl, `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`, { bearer: selectedToken.token } ); assert.equal(afterRevoke.status, 200); assert.equal(afterRevoke.body.total, 0); const detailAfterRevoke = await requestJson( fixture.baseUrl, `/api/inbound-messages/${seeded.sharedMessageId}`, { bearer: selectedToken.token } ); assert.equal(detailAfterRevoke.status, 404); }); await t.test('Bearer send scope is intersected with token mailbox selection and live send grants', async () => { await replaceGrants( fixture.baseUrl, adminCookie, seeded.sharedMailboxId, [{ userId: seeded.readerUserId, permissions: ['send'] }] ); const ownerToken = await createToken(fixture.baseUrl, readerCookie, { name: 'send owner scope', scopes: ['send'], mailboxAccess: 'owner' }); const selectedSharedToken = await createToken(fixture.baseUrl, readerCookie, { name: 'send selected shared mailbox', scopes: ['send'], mailboxAccess: 'selected', mailboxIds: [seeded.sharedMailboxId] }); const selectedOwnToken = await createToken(fixture.baseUrl, readerCookie, { name: 'send selected owned mailbox', scopes: ['send'], mailboxAccess: 'selected', mailboxIds: [seeded.readerMailboxId] }); const mailFromCount = smtpMailFromCommands(smtp).length; await assertSendDenied(fixture.baseUrl, { bearer: ownerToken.token, from: 'shared@access-owner.example' }); await assertSendDenied(fixture.baseUrl, { bearer: selectedOwnToken.token, from: 'shared@access-owner.example' }); const selectedSend = await sendApiMessage(fixture.baseUrl, { bearer: selectedSharedToken.token, from: 'shared@access-owner.example', subject: 'Selected token mailbox grant send' }); assert.equal(selectedSend.status, 202, JSON.stringify(selectedSend.body)); assert.equal(selectedSend.body.queued, true); assert.deepEqual( smtpMailFromCommands(smtp).slice(mailFromCount), ['MAIL FROM:'] ); await replaceGrants(fixture.baseUrl, adminCookie, seeded.sharedMailboxId, []); await assertSendDenied(fixture.baseUrl, { bearer: selectedSharedToken.token, from: 'shared@access-owner.example' }); assert.equal(smtpMailFromCommands(smtp).length, mailFromCount + 1); }); await t.test('send grants authorize only the exact mailbox address and aliases', async () => { await replaceGrants( fixture.baseUrl, adminCookie, seeded.sharedMailboxId, [ { userId: seeded.readerUserId, permissions: ['send'] }, { userId: seeded.viewerUserId, permissions: ['view'] } ] ); const mailFromCount = smtpMailFromCommands(smtp).length; const readerRelayMailFromCount = smtpMailFromCommands(readerSmtp).length; const forbiddenRelay = await sendApiMessage(fixture.baseUrl, { cookie: readerCookie, from: 'shared@access-owner.example', subject: 'Shared mailbox cannot use grantee relay', smtpRelayId: seeded.readerRelayId }); assert.equal(forbiddenRelay.status, 403, JSON.stringify(forbiddenRelay.body)); assert.match(forbiddenRelay.body.error, /域名所有者.*SMTP 出口/); assert.equal(smtpMailFromCommands(smtp).length, mailFromCount); assert.equal(smtpMailFromCommands(readerSmtp).length, readerRelayMailFromCount); for (const from of ['shared@access-owner.example', 'shared-alias@access-owner.example']) { const sent = await sendApiMessage(fixture.baseUrl, { cookie: readerCookie, from, subject: `Mailbox grant send from ${from}` }); assert.equal(sent.status, 202, JSON.stringify(sent.body)); assert.equal(sent.body.queued, true); } await assertSendDenied(fixture.baseUrl, { cookie: readerCookie, from: 'other@access-owner.example' }); assert.deepEqual( smtpMailFromCommands(smtp).slice(mailFromCount), [ 'MAIL FROM:', 'MAIL FROM:' ] ); }); } finally { fixture.child.kill('SIGTERM'); await waitForExit(fixture.child, 1000); await smtp.close(); await readerSmtp.close(); } }); function seedMailboxAccessFixtures(dataDir, sessionSecret, readerRelayPort) { const script = ` import { createDomain, createInboundMailbox, createInboundMessage, createUser, initDatabase, saveSmtpRelay } from './src/db.js'; import { createDkimKeyPair } from './src/dkim.js'; initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET); const owner = createUser({ username: 'access-owner', email: 'access-owner@example.test', password: 'password123', status: 'active' }); const reader = createUser({ username: 'access-reader', email: 'access-reader@example.test', password: 'password123', status: 'active' }); const viewer = createUser({ username: 'access-viewer', email: 'access-viewer@example.test', password: 'password123', status: 'active' }); const readerRelay = saveSmtpRelay(reader.id, { name: 'Reader private relay', host: '127.0.0.1', port: Number(process.env.READER_RELAY_PORT), secure: false, username: '', password: '', helo: 'mail.access-reader.example' }); const keys = createDkimKeyPair(); const createUserDomain = (user, name) => createDomain(user.id, { domain: name, selector: 'mh', verificationToken: 'verify-' + name, dkimPublic: keys.publicKey, dkimPrivate: keys.privateKey, senderHost: 'mail.' + name, sendingIp: '127.0.0.1', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); createUserDomain(owner, 'access-owner.example'); createUserDomain(reader, 'access-reader.example'); const sharedMailbox = createInboundMailbox(owner.id, { address: 'shared@access-owner.example', password: 'mailbox-password', aliases: ['shared-alias'] }); const otherMailbox = createInboundMailbox(owner.id, { address: 'other@access-owner.example', password: 'mailbox-password' }); const readerMailbox = createInboundMailbox(reader.id, { address: 'own@access-reader.example', password: 'mailbox-password' }); const createMessage = (mailbox, subject, sequence) => createInboundMessage(mailbox, { sender: 'sender@example.net', recipients: [mailbox.address], subject, messageId: '', rawMessage: 'Subject: ' + subject + '\\r\\n\\r\\n' + subject, textBody: subject, receivedAt: '2026-07-18T0' + sequence + ':00:00.000Z' }); const sharedMessage = createMessage(sharedMailbox, 'Shared grant message', 1); createMessage(otherMailbox, 'Other owner message', 2); const readerMessage = createMessage(readerMailbox, 'Reader owned message', 3); console.log(JSON.stringify({ ownerUserId: owner.id, readerUserId: reader.id, viewerUserId: viewer.id, sharedMailboxId: sharedMailbox.id, otherMailboxId: otherMailbox.id, readerMailboxId: readerMailbox.id, readerRelayId: readerRelay.id, sharedMessageId: sharedMessage.id, readerMessageId: readerMessage.id })); `; const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { cwd: process.cwd(), env: { ...process.env, DATA_DIR: dataDir, SESSION_SECRET: sessionSecret, READER_RELAY_PORT: String(readerRelayPort) }, encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr || result.stdout); return JSON.parse(result.stdout); } async function replaceGrants(baseUrl, adminCookie, mailboxId, grants) { const response = await requestJson( baseUrl, `/api/admin/inbound-mailboxes/${mailboxId}/access`, { method: 'PUT', cookie: adminCookie, body: { grants } } ); assert.equal(response.status, 200, JSON.stringify(response.body)); return response.body.mailbox; } function findAdminMailbox(mailboxes, mailboxId) { const mailbox = mailboxes.find((entry) => entry.mailbox.id === mailboxId); assert.ok(mailbox, `expected admin mailbox ${mailboxId}`); return mailbox; } function grantPermissions(mailboxAccess, userId) { const grant = mailboxAccess.grants.find((entry) => entry.user.id === userId); assert.ok(grant, `expected grant for user ${userId}`); return grant.permissions; } async function mailboxAddresses(baseUrl, pathname, cookie) { const response = await requestJson(baseUrl, pathname, { cookie }); assert.equal(response.status, 200, JSON.stringify(response.body)); return response.body.mailboxes.map((mailbox) => mailbox.address).sort(); } async function createToken(baseUrl, cookie, input) { const response = await requestJson(baseUrl, '/api/api-tokens', { method: 'POST', cookie, body: input }); assert.equal(response.status, 201, JSON.stringify(response.body)); assert.ok(response.body.token.token); return response.body.token; } function sendApiMessage(baseUrl, { cookie = '', bearer = '', from, subject = 'Mailbox grant send', smtpRelayId = null }) { return requestJson(baseUrl, '/api/send', { method: 'POST', cookie, bearer, body: { from, to: 'recipient@example.net', subject, text: 'mailbox grant send test', ...(smtpRelayId ? { smtpRelayId } : {}) } }); } async function assertSendDenied(baseUrl, input) { const response = await sendApiMessage(baseUrl, { ...input, subject: 'Mailbox grant denial' }); assert.equal(response.status, 403, JSON.stringify(response.body)); assert.match(response.body.error, /未获得发信权限/); } function smtpMailFromCommands(smtp) { return smtp.commands.filter((command) => command.startsWith('MAIL FROM:')); } async function requestJson(baseUrl, pathname, { method = 'GET', cookie = '', bearer = '', body } = {}) { const headers = {}; if (cookie) headers.Cookie = cookie; if (bearer) headers.Authorization = `Bearer ${bearer}`; if (body !== undefined) headers['Content-Type'] = 'application/json'; const response = await fetch(`${baseUrl}${pathname}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual' }); const text = await response.text(); return { status: response.status, body: text ? JSON.parse(text) : null }; } async function login(baseUrl, username, password) { const response = await fetch(`${baseUrl}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); assert.equal(response.status, 200); const cookie = response.headers.get('set-cookie')?.split(';')[0] || ''; assert.ok(cookie); return cookie; } async function startTestServer(smtpPort) { const port = await freePort(); const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-access-')); const sessionSecret = 'mailbox-access-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.test', 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', SMTP_HOST: '127.0.0.1', SMTP_PORT: String(smtpPort), SMTP_SECURE: 'false', SMTP_HELO: 'mailhub-access.test' }, stdio: ['ignore', 'pipe', 'pipe'] }); await waitForOutput(child, 'MailHub listening'); return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret }; } 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 ACCESS123\r\n'); } continue; } commands.push(line); if (line.startsWith('EHLO')) socket.write('250 relay.test\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)) }); }); }); } 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.')); }); }); server.on('error', reject); }); } function waitForOutput(child, text, timeoutMs = 8000) { return new Promise((resolve, reject) => { let output = ''; const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}\n${output}`)), timeoutMs); const onData = (chunk) => { output += chunk.toString(); if (!output.includes(text)) return; 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 before startup with code ${code}\n${output}`)); }); }); } function waitForExit(child, timeoutMs) { if (child.exitCode !== null) return Promise.resolve(child.exitCode); return new Promise((resolve) => { const timeout = setTimeout(() => resolve(null), timeoutMs); child.once('exit', (code) => { clearTimeout(timeout); resolve(code); }); }); }