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 { DatabaseSync } from 'node:sqlite'; 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('administrators can update selected mailbox grants in bulk without replacing unrelated access', async () => { const ordinaryBulk = await bulkUpdateGrants(fixture.baseUrl, readerCookie, { mailboxIds: [seeded.sharedMailboxId], userIds: [seeded.viewerUserId], operation: 'upsert', permissions: { view: true, receive: false, send: false } }); assert.equal(ordinaryBulk.status, 403); await replaceGrants( fixture.baseUrl, adminCookie, seeded.readerMailboxId, [{ userId: seeded.ownerUserId, permissions: ['view'] }] ); const updated = await bulkUpdateGrants(fixture.baseUrl, adminCookie, { mailboxIds: [seeded.sharedMailboxId, seeded.otherMailboxId, seeded.readerMailboxId], userIds: [seeded.readerUserId, seeded.viewerUserId], operation: 'upsert', permissions: { view: false, receive: true, send: false } }); assert.equal(updated.status, 200, JSON.stringify(updated.body)); assert.deepEqual(updated.body.summary, { mailboxCount: 3, userCount: 2, pairCount: 6, changedGrantCount: 5, unchangedGrantCount: 0, skippedOwnerCount: 1, revokedWebmailSessionCount: 0 }); assert.deepEqual( updated.body.mailboxes.map((entry) => entry.mailbox.id), [seeded.sharedMailboxId, seeded.otherMailboxId, seeded.readerMailboxId] ); for (const mailboxId of [seeded.sharedMailboxId, seeded.otherMailboxId]) { const entry = findAdminMailbox(updated.body.mailboxes, mailboxId); for (const userId of [seeded.readerUserId, seeded.viewerUserId]) { assert.deepEqual(grantPermissions(entry, userId), { view: true, receive: true, send: false }); } } const readerMailbox = findAdminMailbox(updated.body.mailboxes, seeded.readerMailboxId); assert.deepEqual(grantPermissions(readerMailbox, seeded.ownerUserId), { view: true, receive: false, send: false }); assert.equal( readerMailbox.grants.some((grant) => grant.user.id === seeded.readerUserId), false ); assert.deepEqual(grantPermissions(readerMailbox, seeded.viewerUserId), { view: true, receive: true, send: false }); const audit = await requestJson( fixture.baseUrl, '/api/admin/audit-logs?action=admin.bulk_update_mailbox_access', { cookie: adminCookie } ); assert.equal(audit.status, 200); assert.equal(audit.body.logs.length, 1); assert.equal(audit.body.logs[0].summary.operation, 'upsert'); assert.deepEqual(audit.body.logs[0].summary.mailboxIds, [ seeded.sharedMailboxId, seeded.otherMailboxId, seeded.readerMailboxId ]); const unchanged = await bulkUpdateGrants(fixture.baseUrl, adminCookie, { mailboxIds: [seeded.otherMailboxId], userIds: [seeded.viewerUserId], operation: 'upsert', permissions: { view: false, receive: true, send: false } }); assert.equal(unchanged.status, 200, JSON.stringify(unchanged.body)); assert.equal(unchanged.body.summary.changedGrantCount, 0); assert.equal(unchanged.body.summary.unchangedGrantCount, 1); const loweredSessionId = insertPendingWebmailSession( fixture.dataDir, seeded.readerUserId, seeded.sharedMailboxId ); const lowered = await bulkUpdateGrants(fixture.baseUrl, adminCookie, { mailboxIds: [seeded.sharedMailboxId], userIds: [seeded.readerUserId], operation: 'upsert', permissions: { view: false, receive: false, send: true } }); assert.equal(lowered.status, 200, JSON.stringify(lowered.body)); assert.equal(lowered.body.summary.changedGrantCount, 1); assert.equal(lowered.body.summary.revokedWebmailSessionCount, 1); assert.ok(webmailSessionRevokedAt(fixture.dataDir, loweredSessionId)); const restoredReceive = await bulkUpdateGrants(fixture.baseUrl, adminCookie, { mailboxIds: [seeded.sharedMailboxId], userIds: [seeded.readerUserId], operation: 'upsert', permissions: { view: false, receive: true, send: false } }); assert.equal(restoredReceive.status, 200, JSON.stringify(restoredReceive.body)); const removedSessionId = insertPendingWebmailSession( fixture.dataDir, seeded.readerUserId, seeded.sharedMailboxId ); const removed = await bulkUpdateGrants(fixture.baseUrl, adminCookie, { mailboxIds: [seeded.sharedMailboxId], userIds: [seeded.readerUserId], operation: 'remove' }); assert.equal(removed.status, 200, JSON.stringify(removed.body)); assert.equal(removed.body.summary.changedGrantCount, 1); assert.equal(removed.body.summary.revokedWebmailSessionCount, 1); assert.ok(webmailSessionRevokedAt(fixture.dataDir, removedSessionId)); assert.deepEqual( grantPermissions(findAdminMailbox(removed.body.mailboxes, seeded.sharedMailboxId), seeded.viewerUserId), { view: true, receive: true, send: false } ); const beforeRejectedBatch = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', { cookie: adminCookie }); const beforeViewerPermissions = grantPermissions( findAdminMailbox(beforeRejectedBatch.body.mailboxes, seeded.sharedMailboxId), seeded.viewerUserId ); const deletedMailboxBatch = await bulkUpdateGrants(fixture.baseUrl, adminCookie, { mailboxIds: [seeded.sharedMailboxId, seeded.deletedMailboxId], userIds: [seeded.viewerUserId], operation: 'upsert', permissions: { view: true, receive: false, send: true } }); assert.equal(deletedMailboxBatch.status, 404); assert.equal(deletedMailboxBatch.body.code, 'BULK_MAILBOX_ACCESS_MAILBOX_NOT_FOUND'); const disabledUserUpsert = await bulkUpdateGrants(fixture.baseUrl, adminCookie, { mailboxIds: [seeded.sharedMailboxId], userIds: [seeded.disabledUserId], operation: 'upsert', permissions: { view: true, receive: false, send: false } }); assert.equal(disabledUserUpsert.status, 409); assert.equal(disabledUserUpsert.body.code, 'BULK_MAILBOX_ACCESS_USER_DISABLED'); const disabledUserRemoval = await bulkUpdateGrants(fixture.baseUrl, adminCookie, { mailboxIds: [seeded.otherMailboxId], userIds: [seeded.disabledUserId], operation: 'remove' }); assert.equal(disabledUserRemoval.status, 200, JSON.stringify(disabledUserRemoval.body)); assert.equal(disabledUserRemoval.body.summary.changedGrantCount, 1); assert.equal( findAdminMailbox(disabledUserRemoval.body.mailboxes, seeded.otherMailboxId) .grants.some((grant) => grant.user.id === seeded.disabledUserId), false ); const missingUserBatch = await bulkUpdateGrants(fixture.baseUrl, adminCookie, { mailboxIds: [seeded.sharedMailboxId], userIds: [seeded.viewerUserId, 999_999], operation: 'remove' }); assert.equal(missingUserBatch.status, 404); assert.equal(missingUserBatch.body.code, 'BULK_MAILBOX_ACCESS_USER_NOT_FOUND'); const oversizedBatch = await bulkUpdateGrants(fixture.baseUrl, adminCookie, { mailboxIds: Array.from({ length: 501 }, (_value, index) => index + 1), userIds: [seeded.viewerUserId], operation: 'remove' }); assert.equal(oversizedBatch.status, 400); assert.equal(oversizedBatch.body.code, 'BULK_MAILBOX_ACCESS_LIMIT_EXCEEDED'); const excessiveCombinationBatch = await bulkUpdateGrants(fixture.baseUrl, adminCookie, { mailboxIds: Array.from({ length: 101 }, (_value, index) => index + 1), userIds: Array.from({ length: 50 }, (_value, index) => index + 1_000), operation: 'remove' }); assert.equal(excessiveCombinationBatch.status, 400); assert.equal(excessiveCombinationBatch.body.code, 'BULK_MAILBOX_ACCESS_LIMIT_EXCEEDED'); const afterRejectedBatch = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', { cookie: adminCookie }); assert.deepEqual( grantPermissions( findAdminMailbox(afterRejectedBatch.body.mailboxes, seeded.sharedMailboxId), seeded.viewerUserId ), beforeViewerPermissions ); await replaceGrants( fixture.baseUrl, adminCookie, seeded.sharedMailboxId, [ { userId: seeded.readerUserId, permissions: ['receive', 'send'] }, { userId: seeded.viewerUserId, permissions: ['view'] } ] ); await replaceGrants(fixture.baseUrl, adminCookie, seeded.otherMailboxId, []); await replaceGrants(fixture.baseUrl, adminCookie, seeded.readerMailboxId, []); }); 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 { DatabaseSync } from 'node:sqlite'; 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 disabled = createUser({ username: 'access-disabled', email: 'access-disabled@example.test', password: 'password123', status: 'disabled' }); 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 deletedMailbox = createInboundMailbox(owner.id, { address: 'deleted@access-owner.example', password: 'mailbox-password' }); const fixtureDatabase = new DatabaseSync(process.env.DATA_DIR + '/mailhub.sqlite'); fixtureDatabase .prepare("UPDATE inbound_mailboxes SET status = 'disabled', deleted_at = ?, updated_at = ? WHERE id = ?") .run('2026-07-18T00:00:00.000Z', '2026-07-18T00:00:00.000Z', deletedMailbox.id); fixtureDatabase .prepare(\` INSERT INTO inbound_mailbox_grants ( mailbox_id, user_id, can_view, can_receive, can_send, created_at, updated_at ) VALUES (?, ?, 'true', 'false', 'false', ?, ?) \`) .run( otherMailbox.id, disabled.id, '2026-07-18T00:00:00.000Z', '2026-07-18T00:00:00.000Z' ); fixtureDatabase.close(); 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, disabledUserId: disabled.id, sharedMailboxId: sharedMailbox.id, otherMailboxId: otherMailbox.id, readerMailboxId: readerMailbox.id, deletedMailboxId: deletedMailbox.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 bulkUpdateGrants(baseUrl, cookie, body) { return requestJson(baseUrl, '/api/admin/inbound-mailboxes/access/bulk', { method: 'POST', cookie, body }); } let webmailSessionSequence = 0; function insertPendingWebmailSession(dataDir, actorUserId, mailboxId) { webmailSessionSequence += 1; const database = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite')); try { const createdAt = new Date().toISOString(); const result = database .prepare(` INSERT INTO webmail_sessions ( actor_user_id, mailbox_id, audience, ticket_hash, ticket_expires_at, credential_expires_at, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?) `) .run( actorUserId, mailboxId, 'https://webmail.example.test', `bulk-access-test-ticket-${process.pid}-${webmailSessionSequence}`, '2099-01-01T00:00:00.000Z', '2099-01-01T00:00:00.000Z', createdAt ); return Number(result.lastInsertRowid); } finally { database.close(); } } function webmailSessionRevokedAt(dataDir, sessionId) { const database = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'), { readOnly: true }); try { return database .prepare('SELECT revoked_at FROM webmail_sessions WHERE id = ?') .get(sessionId)?.revoked_at || ''; } finally { database.close(); } } 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); }); }); }