import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync } from 'node:fs'; import { chmod, rename as renameFile, stat as statFile, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { bulkUpdateInboundMailboxGrants, completeInboundMailboxDeletionJob, createApiToken, createDomain, createInboundFolder, createInboundMailbox, createInboundMessage, createInboundMessageWithWebhook, createImportedInboundMessage, createUser, createWebhook, createWebmailLoginTicket, deleteDomain, deleteInboundMailboxWithMessageTransfer, executeUserMerge, getInboundMailbox, getInboundMailboxByAddress, getInboundMessage, initDatabase, listPendingInboundMailboxDeletionJobs, listInboundMailboxFolders, listInboundMailboxTransferMessages, markInboundMessageRead, prepareInboundMailboxDeletion, recordInboundMessageMaildirStorage, replaceInboundMailboxGrants, restorePreparedInboundMailboxDeletion, transferDomain, updateInboundMailboxDeletionJob, updateDomain, updateInboundMailbox, updateWebhook } from '../src/db.js'; import { publishInboundMailboxMaildirTransfer, recoverPendingInboundMailboxDeletions, rollbackInboundMailboxMaildirTransfer, stageInboundMailboxMaildirTransfer, verifyPublishedInboundMailboxMaildirTransfer } from '../src/inbound-mailbox-delete.js'; import { assertQuarantinedMaildirMailboxUnchanged, quarantineMaildirMailbox, readMaildirMessage, restoreQuarantinedMaildirMailbox, scanMaildirMailbox, snapshotQuarantinedMaildirMailbox, writeMaildirMessage } from '../src/maildir-store.js'; test('owned mailbox deletion moves visible messages and retires mailbox access state atomically', () => { const database = initDatabase( mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-db-')), 'mailbox-delete-secret' ); const owner = createUser({ username: 'delete-owner', email: 'delete-owner@example.com', password: 'password123' }); const assignee = createUser({ username: 'delete-assignee', email: 'delete-assignee@example.com', password: 'password123' }); const sourceDomain = createTestDomain(owner.id, 'delete-source.example'); const targetDomain = createTestDomain(owner.id, 'delete-target.example'); const source = createInboundMailbox(owner.id, { address: 'source@delete-source.example' }); const target = createInboundMailbox(owner.id, { address: 'target@delete-target.example' }); createInboundFolder(source, 'Projects/2026'); replaceInboundMailboxGrants(source.id, [{ userId: assignee.id, receive: true }]); const webmail = createWebmailLoginTicket(owner.id, source.id, { audience: 'https://roundcube.test' }); const webhook = createWebhook(owner.id, { name: 'source received', url: 'https://hooks.example.com/received', events: ['received'], mailboxId: source.id }); const visibleMessage = createInboundMessage(source, { folder: 'Projects/2026', sender: 'sender@example.net', recipients: [source.address], subject: 'Move me', rawMessage: 'Subject: Move me\r\n\r\nOriginal raw MIME', textBody: 'Original raw MIME' }); database.prepare(` UPDATE inbound_messages SET read_state = 'true', flags_json = '["\\\\Seen","\\\\Flagged"]', keywords_json = '["important"]' WHERE id = ? `).run(visibleMessage.id); const removedMessage = createInboundMessage(source, { sender: 'removed@example.net', recipients: [source.address], subject: 'Already removed', rawMessage: 'Subject: Already removed\r\n\r\nRemoved body', textBody: 'Removed body' }); database.prepare('UPDATE inbound_messages SET deleted_at = ? WHERE id = ?') .run(new Date().toISOString(), removedMessage.id); const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: source.address }); assert.equal(prepared.sourceMailbox.id, source.id); assert.equal(prepared.targetMailbox.id, target.id); assert.equal(prepared.originalSourceStatus, 'active'); assert.equal(getInboundMailboxByAddress(source.address), null); const result = deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id); assert.deepEqual(result.deletedMailbox, { id: source.id, address: source.address }); assert.equal(result.deleted, true); assert.equal(result.targetMailbox.id, target.id); assert.equal(result.targetMailbox.messageCount, 1); assert.equal(result.migratedMessageCount, 1); assert.equal(getInboundMailbox(source.id, owner.id), null); const moved = getInboundMessage(owner.id, visibleMessage.id); assert.equal(moved.mailboxId, target.id); assert.equal(moved.userId, owner.id); assert.equal(moved.domainId, targetDomain.id); assert.equal(moved.folder, 'Projects/2026'); assert.equal(moved.rawMessage, 'Subject: Move me\r\n\r\nOriginal raw MIME'); assert.deepEqual(moved.flags, ['\\Seen', '\\Flagged']); assert.deepEqual(moved.keywords, ['important']); assert.equal(moved.read, true); assert.ok(listInboundMailboxFolders(owner.id, target.id).some((folder) => folder.name === 'Projects/2026')); const removedRow = database.prepare('SELECT mailbox_id, domain_id, deleted_at FROM inbound_messages WHERE id = ?') .get(removedMessage.id); assert.equal(removedRow.mailbox_id, source.id); assert.equal(removedRow.domain_id, sourceDomain.id); assert.ok(removedRow.deleted_at); assert.equal(database.prepare('SELECT COUNT(*) AS count FROM inbound_mailbox_grants WHERE mailbox_id = ?').get(source.id).count, 0); assert.ok(database.prepare('SELECT revoked_at FROM webmail_sessions WHERE id = ?').get(webmail.id).revoked_at); assert.equal(database.prepare('SELECT enabled FROM webhooks WHERE id = ?').get(webhook.id).enabled, 'false'); assert.ok(database.prepare('SELECT deleted_at FROM inbound_folders WHERE mailbox_id = ? AND name = ?') .get(source.id, 'Projects/2026').deleted_at); assert.equal(deleteDomain(sourceDomain.id, owner.id), true); }); test('mailbox deletion validates ownership, target state, and permits an empty mailbox without a target', () => { const database = initDatabase( mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-validation-')), 'mailbox-delete-validation-secret' ); const owner = createUser({ username: 'validation-owner', email: 'validation-owner@example.com', password: 'password123' }); const other = createUser({ username: 'validation-other', email: 'validation-other@example.com', password: 'password123' }); createTestDomain(owner.id, 'validation-owner.example'); createTestDomain(other.id, 'validation-other.example'); const source = createInboundMailbox(owner.id, { address: 'source@validation-owner.example' }); const target = createInboundMailbox(owner.id, { address: 'target@validation-owner.example' }); const empty = createInboundMailbox(owner.id, { address: 'empty@validation-owner.example' }); const otherTarget = createInboundMailbox(other.id, { address: 'target@validation-other.example' }); createInboundMessage(source, { sender: 'sender@example.net', recipients: [source.address], subject: 'Needs a target', rawMessage: 'Subject: Needs a target\r\n\r\nBody' }); assertDeletionCode( () => prepareInboundMailboxDeletion(owner.id, source.id, target.id), 'INBOUND_MAILBOX_DELETE_CONFIRMATION_MISMATCH' ); assertDeletionCode( () => prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: ` ${source.address}` }), 'INBOUND_MAILBOX_DELETE_CONFIRMATION_MISMATCH' ); assertDeletionCode( () => prepareInboundMailboxDeletion(owner.id, source.id, source.id, { confirmAddress: source.address }), 'INBOUND_MAILBOX_DELETE_SAME_TARGET' ); assertDeletionCode( () => prepareInboundMailboxDeletion(owner.id, source.id, otherTarget.id, { confirmAddress: source.address }), 'INBOUND_MAILBOX_DELETE_TARGET_NOT_FOUND' ); assertDeletionCode( () => prepareInboundMailboxDeletion(owner.id, source.id, null, { confirmAddress: source.address }), 'INBOUND_MAILBOX_DELETE_TARGET_REQUIRED' ); updateInboundMailbox(owner.id, target.id, { status: 'disabled' }); assertDeletionCode( () => prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: source.address }), 'INBOUND_MAILBOX_DELETE_TARGET_INACTIVE' ); const preparedEmpty = prepareInboundMailboxDeletion(owner.id, empty.id, null, { confirmAddress: empty.address }); assert.equal(preparedEmpty.targetMailbox, null); const deletedEmpty = deleteInboundMailboxWithMessageTransfer(owner.id, empty.id, null); assert.equal(deletedEmpty.targetMailbox, null); assert.equal(deletedEmpty.migratedMessageCount, 0); updateInboundMailbox(owner.id, target.id, { status: 'active' }); const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: source.address }); assertDeletionCode( () => deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id, { storageUpdates: [] }), 'INBOUND_MAILBOX_DELETE_MESSAGE_CONFLICT' ); assert.equal(database.prepare('SELECT mailbox_id FROM inbound_messages WHERE mailbox_id = ?').get(source.id).mailbox_id, source.id); assert.equal(restorePreparedInboundMailboxDeletion(owner.id, source.id, prepared.originalSourceStatus), true); assert.equal(getInboundMailboxByAddress(source.address).id, source.id); assert.equal(listInboundMailboxTransferMessages(other.id, source.id), null); const staleRoute = getInboundMailboxByAddress(source.address); updateInboundMailbox(owner.id, source.id, { status: 'disabled' }); assertDeletionCode( () => createInboundMessageWithWebhook(staleRoute, { sender: 'late@example.net', recipients: [source.address], rawMessage: 'Subject: Too late\r\n\r\nBody' }), 'INBOUND_MAILBOX_DELIVERY_UNAVAILABLE' ); assert.equal(database.prepare(` SELECT COUNT(*) AS count FROM inbound_messages WHERE mailbox_id = ? AND deleted_at IS NULL `).get(source.id).count, 1); }); test('mailbox deletion reports catch-all, forwarding, and selected-token blockers without rebinding them', () => { initDatabase( mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-blockers-')), 'mailbox-delete-blockers-secret' ); const owner = createUser({ username: 'blocker-owner', email: 'blocker-owner@example.com', password: 'password123' }); const domain = createTestDomain(owner.id, 'delete-blockers.example'); const source = createInboundMailbox(owner.id, { address: 'source@delete-blockers.example' }); const target = createInboundMailbox(owner.id, { address: 'target@delete-blockers.example' }); const forwarding = createInboundMailbox(owner.id, { address: 'forwarding@delete-blockers.example' }); updateDomain(domain.id, owner.id, { catchAllAddress: source.address }); updateInboundMailbox(owner.id, forwarding.id, { forwardTo: [source.address] }); createApiToken(owner.id, 'selected source', { scopes: ['mailboxes:read'], mailboxAccess: 'selected', mailboxIds: [source.id] }); assert.throws( () => prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: source.address }), (error) => { assert.equal(error.code, 'INBOUND_MAILBOX_DELETE_REFERENCES_BLOCKED'); assert.deepEqual(error.blockers, [ { type: 'domainCatchAll', count: 1 }, { type: 'mailboxForwarding', count: 1 }, { type: 'apiTokenSelection', count: 1 } ]); return true; } ); assert.equal(getInboundMailboxByAddress(source.address).status, 'active'); assert.equal(getInboundMailboxByAddress(forwarding.address).forwardTo[0], source.address); const lateSource = createInboundMailbox(owner.id, { address: 'late-source@delete-blockers.example' }); const prepared = prepareInboundMailboxDeletion(owner.id, lateSource.id, target.id, { confirmAddress: lateSource.address }); updateInboundMailbox(owner.id, forwarding.id, { forwardTo: [lateSource.address] }); assert.throws( () => deleteInboundMailboxWithMessageTransfer(owner.id, lateSource.id, target.id), (error) => { assert.equal(error.code, 'INBOUND_MAILBOX_DELETE_REFERENCES_BLOCKED'); assert.deepEqual(error.blockers, [{ type: 'mailboxForwarding', count: 1 }]); return true; } ); assert.equal(restorePreparedInboundMailboxDeletion( owner.id, lateSource.id, prepared.originalSourceStatus ), true); assert.equal(getInboundMailboxByAddress(lateSource.address).status, 'active'); }); test('mailbox deletion job rejects a skipped filesystem phase and mailbox address drift', () => { const database = initDatabase( mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-job-guard-')), 'mailbox-delete-job-guard-secret' ); const owner = createUser({ username: 'job-guard-owner', email: 'job-guard-owner@example.com', password: 'password123' }); const mergeTarget = createUser({ username: 'job-guard-target', email: 'job-guard-target@example.com', password: 'password123' }); const domain = createTestDomain(owner.id, 'job-guard.example'); const target = createInboundMailbox(owner.id, { address: 'target@job-guard.example' }); const phaseSource = createInboundMailbox(owner.id, { address: 'phase-source@job-guard.example' }); const phaseJobId = '10000000000000000000000000000001'; const phasePrepared = prepareInboundMailboxDeletion(owner.id, phaseSource.id, target.id, { confirmAddress: phaseSource.address, jobId: phaseJobId }); assertDeletionCode( () => deleteInboundMailboxWithMessageTransfer(owner.id, phaseSource.id, target.id, { deletionJobId: phaseJobId }), 'INBOUND_MAILBOX_DELETE_NOT_PREPARED' ); assertDeletionCode( () => updateInboundMailbox(owner.id, phaseSource.id, { status: 'active' }), 'INBOUND_MAILBOX_DELETE_IN_PROGRESS' ); assertDeletionCode( () => updateInboundMailbox(owner.id, target.id, { displayName: 'Must stay stable' }), 'INBOUND_MAILBOX_DELETE_IN_PROGRESS' ); assertDeletionCode( () => transferDomain({ actorUserId: owner.id, domainId: domain.id, targetUserId: mergeTarget.id }), 'INBOUND_MAILBOX_DELETE_IN_PROGRESS' ); assertDeletionCode( () => executeUserMerge({ actorUserId: owner.id, sourceUserId: owner.id, targetUserId: mergeTarget.id, confirmation: `MERGE ${owner.username} INTO ${mergeTarget.username}` }), 'INBOUND_MAILBOX_DELETE_IN_PROGRESS' ); assert.equal(database.prepare('SELECT user_id FROM domains WHERE id = ?').get(domain.id).user_id, owner.id); assert.equal(restorePreparedInboundMailboxDeletion( owner.id, phaseSource.id, phasePrepared.originalSourceStatus ), true); assert.equal(completeInboundMailboxDeletionJob(phaseJobId, 'rolled_back'), true); assert.equal(updateInboundMailbox(owner.id, target.id, { displayName: 'Stable again' }).displayName, 'Stable again'); const source = createInboundMailbox(owner.id, { address: 'source@job-guard.example' }); const sourceJobId = '10000000000000000000000000000002'; const sourcePrepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: source.address, jobId: sourceJobId }); updateInboundMailboxDeletionJob(sourceJobId, { phase: 'committing_database' }); database.prepare('UPDATE inbound_mailboxes SET address = ? WHERE id = ?') .run('renamed-source@job-guard.example', source.id); assertDeletionCode( () => deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id, { deletionJobId: sourceJobId }), 'INBOUND_MAILBOX_DELETE_NOT_PREPARED' ); assert.equal(restorePreparedInboundMailboxDeletion( owner.id, source.id, sourcePrepared.originalSourceStatus ), true); assert.equal(completeInboundMailboxDeletionJob(sourceJobId, 'rolled_back'), true); const targetDriftSource = createInboundMailbox(owner.id, { address: 'target-drift-source@job-guard.example' }); const targetJobId = '10000000000000000000000000000003'; const targetPrepared = prepareInboundMailboxDeletion(owner.id, targetDriftSource.id, target.id, { confirmAddress: targetDriftSource.address, jobId: targetJobId }); updateInboundMailboxDeletionJob(targetJobId, { phase: 'committing_database' }); database.prepare('UPDATE inbound_mailboxes SET address = ? WHERE id = ?') .run('renamed-target@job-guard.example', target.id); assertDeletionCode( () => deleteInboundMailboxWithMessageTransfer(owner.id, targetDriftSource.id, target.id, { deletionJobId: targetJobId }), 'INBOUND_MAILBOX_DELETE_NOT_PREPARED' ); assert.equal(restorePreparedInboundMailboxDeletion( owner.id, targetDriftSource.id, targetPrepared.originalSourceStatus ), true); assert.equal(completeInboundMailboxDeletionJob(targetJobId, 'rolled_back'), true); }); test('pending mailbox deletion blocks grants, mailbox webhooks, and message mutations', () => { const database = initDatabase( mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-mutation-guard-')), 'mailbox-delete-mutation-guard-secret' ); const owner = createUser({ username: 'mutation-guard-owner', email: 'mutation-guard-owner@example.com', password: 'password123' }); const assignee = createUser({ username: 'mutation-guard-assignee', email: 'mutation-guard-assignee@example.com', password: 'password123' }); createTestDomain(owner.id, 'mutation-guard.example'); const source = createInboundMailbox(owner.id, { address: 'source@mutation-guard.example' }); const target = createInboundMailbox(owner.id, { address: 'target@mutation-guard.example' }); const inboundMessage = createInboundMessage(source, { sender: 'sender@example.net', recipients: [source.address], subject: 'Mutation guard', rawMessage: 'Subject: Mutation guard\r\n\r\nBody' }); const existingWebhook = createWebhook(owner.id, { name: 'existing mailbox webhook', url: 'https://hooks.example.com/existing', events: ['received'], mailboxId: source.id }); const jobId = '10000000000000000000000000000004'; const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: source.address, jobId }); assertDeletionCode( () => replaceInboundMailboxGrants(source.id, [{ userId: assignee.id, receive: true }]), 'INBOUND_MAILBOX_DELETE_IN_PROGRESS' ); assertDeletionCode( () => bulkUpdateInboundMailboxGrants({ actorUserId: owner.id, mailboxIds: [target.id], userIds: [assignee.id], operation: 'upsert', permissions: { receive: true } }), 'INBOUND_MAILBOX_DELETE_IN_PROGRESS' ); assertDeletionCode( () => createWebhook(owner.id, { name: 'late mailbox webhook', url: 'https://hooks.example.com/late', events: ['received'], mailboxId: source.id }), 'INBOUND_MAILBOX_DELETE_IN_PROGRESS' ); assertDeletionCode( () => updateWebhook(owner.id, existingWebhook.id, { name: 'must not change' }), 'INBOUND_MAILBOX_DELETE_IN_PROGRESS' ); assertDeletionCode( () => recordInboundMessageMaildirStorage(inboundMessage.id, { key: 'mhsmtp-mutation-guard', relpath: 'mail/cur/mhsmtp-mutation-guard:2,S', size: 1, mtimeMs: 1, indexedAt: '2026-01-02T03:04:05.000Z' }, { mailboxId: source.id }), 'INBOUND_MAILBOX_DELETE_IN_PROGRESS' ); assertDeletionCode( () => markInboundMessageRead(owner.id, inboundMessage.id, true, {}, { mailboxId: source.id }), 'INBOUND_MAILBOX_DELETE_IN_PROGRESS' ); assert.equal(database.prepare('SELECT COUNT(*) AS count FROM inbound_mailbox_grants').get().count, 0); assert.equal(database.prepare('SELECT name FROM webhooks WHERE id = ?').get(existingWebhook.id).name, 'existing mailbox webhook'); const unchangedMessage = database.prepare(` SELECT read_state, storage_backend FROM inbound_messages WHERE id = ? `).get(inboundMessage.id); assert.equal(unchangedMessage.read_state, 'false'); assert.equal(unchangedMessage.storage_backend, 'sqlite'); assert.equal(restorePreparedInboundMailboxDeletion( owner.id, source.id, prepared.originalSourceStatus ), true); assert.equal(completeInboundMailboxDeletionJob(jobId, 'rolled_back'), true); const driftSource = createInboundMailbox(owner.id, { address: 'drift-source@mutation-guard.example' }); const driftMessage = createInboundMessage(driftSource, { sender: 'sender@example.net', recipients: [driftSource.address], subject: 'Mailbox drift guard', rawMessage: 'Subject: Mailbox drift guard\r\n\r\nBody' }); prepareInboundMailboxDeletion(owner.id, driftSource.id, target.id, { confirmAddress: driftSource.address }); deleteInboundMailboxWithMessageTransfer(owner.id, driftSource.id, target.id); assert.equal(recordInboundMessageMaildirStorage(driftMessage.id, { key: 'mhsmtp-stale-source-write', relpath: 'mail/cur/mhsmtp-stale-source-write:2,S', size: 1, mtimeMs: 1, indexedAt: '2026-01-02T03:04:05.000Z' }, { mailboxId: driftSource.id }), false); assert.equal( markInboundMessageRead(owner.id, driftMessage.id, true, {}, { mailboxId: driftSource.id }), null ); const movedMessage = getInboundMessage(owner.id, driftMessage.id); assert.equal(movedMessage.mailboxId, target.id); assert.equal(movedMessage.read, false); assert.equal( database.prepare('SELECT storage_backend FROM inbound_messages WHERE id = ?').get(driftMessage.id).storage_backend, 'sqlite' ); }); test('mailbox deletion preparation rejects overlapping source and target participants', () => { const database = initDatabase( mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-overlap-guard-')), 'mailbox-delete-overlap-guard-secret' ); const owner = createUser({ username: 'overlap-guard-owner', email: 'overlap-guard-owner@example.com', password: 'password123' }); createTestDomain(owner.id, 'overlap-guard.example'); const source = createInboundMailbox(owner.id, { address: 'source@overlap-guard.example' }); const target = createInboundMailbox(owner.id, { address: 'target@overlap-guard.example' }); const third = createInboundMailbox(owner.id, { address: 'third@overlap-guard.example' }); const firstJobId = '10000000000000000000000000000005'; const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: source.address, jobId: firstJobId }); assertDeletionCode( () => prepareInboundMailboxDeletion(owner.id, target.id, third.id, { confirmAddress: target.address, jobId: '10000000000000000000000000000006' }), 'INBOUND_MAILBOX_DELETE_IN_PROGRESS' ); assertDeletionCode( () => prepareInboundMailboxDeletion(owner.id, third.id, target.id, { confirmAddress: third.address, jobId: '10000000000000000000000000000007' }), 'INBOUND_MAILBOX_DELETE_IN_PROGRESS' ); assertDeletionCode( () => prepareInboundMailboxDeletion(owner.id, third.id, source.id, { confirmAddress: third.address, jobId: '10000000000000000000000000000008' }), 'INBOUND_MAILBOX_DELETE_IN_PROGRESS' ); assert.equal(database.prepare(` SELECT COUNT(*) AS count FROM inbound_mailbox_deletion_jobs WHERE completed_at IS NULL `).get().count, 1); assert.equal(database.prepare('SELECT status FROM inbound_mailboxes WHERE id = ?').get(source.id).status, 'disabled'); assert.equal(database.prepare('SELECT status FROM inbound_mailboxes WHERE id = ?').get(target.id).status, 'active'); assert.equal(database.prepare('SELECT status FROM inbound_mailboxes WHERE id = ?').get(third.id).status, 'active'); assert.equal(restorePreparedInboundMailboxDeletion( owner.id, source.id, prepared.originalSourceStatus ), true); assert.equal(completeInboundMailboxDeletionJob(firstJobId, 'rolled_back'), true); }); test('mailbox deletion preparation serializes participant checks before state changes', () => { const dbSource = readFileSync(new URL('../src/db.js', import.meta.url), 'utf8'); const prepareStart = dbSource.indexOf('export function prepareInboundMailboxDeletion'); const prepareEnd = dbSource.indexOf('export function updateInboundMailboxDeletionJob', prepareStart); const prepareSource = dbSource.slice(prepareStart, prepareEnd); const participantGuard = prepareSource.indexOf('assertNoPendingInboundMailboxDeletion({'); const disableSource = prepareSource.indexOf("SET status = 'disabled'"); assert.ok(prepareStart >= 0 && prepareEnd > prepareStart); assert.ok(participantGuard >= 0 && participantGuard < disableSource); assert.match(prepareSource, /\}, \{ immediate: true \}\);/); const serverSource = readFileSync(new URL('../src/server.js', import.meta.url), 'utf8'); const mapperStart = serverSource.indexOf('function sendInboundMailboxDeletionError'); const mapperEnd = serverSource.indexOf('function auditUserIdParam', mapperStart); assert.match( serverSource.slice(mapperStart, mapperEnd), /'INBOUND_MAILBOX_DELETE_IN_PROGRESS'/ ); }); test('Dovecot mailbox transfer stages target files, keeps source recovery files, and supports rollback', async () => { const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-maildir-')); const root = path.join(dataDir, 'maildir'); initDatabase(dataDir, 'mailbox-delete-maildir-secret'); const owner = createUser({ username: 'maildir-owner', email: 'maildir-owner@example.com', password: 'password123' }); createTestDomain(owner.id, 'maildir-delete.example'); const source = createInboundMailbox(owner.id, { address: 'source@maildir-delete.example' }); const target = createInboundMailbox(owner.id, { address: 'target@maildir-delete.example' }); const sourceRaw = Buffer.from('Subject: Physical\r\n\r\nPhysical body'); const sourceStorage = await writeMaildirMessage({ root, address: source.address, rawMessageBytes: sourceRaw, folder: 'Archive', flags: ['\\Seen', '\\Flagged'], keywords: ['keep-me'], read: true, storageKey: 'mhsmtp-shared-key', receivedAt: '2026-01-02T03:04:05.000Z' }); createImportedInboundMessage(source, { importSource: 'mailbox-delete-test', sourceKey: 'physical-message', folder: 'Archive', flags: ['\\Seen', '\\Flagged'], keywords: ['keep-me'], read: true, receivedAt: '2026-01-02T03:04:05.000Z', rawMessageBytes: sourceRaw, storage: sourceStorage }); createInboundMessage(source, { sender: 'sqlite@example.net', recipients: [source.address], subject: 'SQLite message', rawMessage: 'Subject: SQLite message\r\n\r\nSQLite body', textBody: 'SQLite body' }); const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: source.address }); const messages = listInboundMailboxTransferMessages(owner.id, source.id); const staged = await stageInboundMailboxMaildirTransfer({ root, sourceMailbox: prepared.sourceMailbox, targetMailbox: prepared.targetMailbox, messages, reservedStorageKeys: ['mhsmtp-shared-key'] }); assert.equal(staged.storageUpdates.length, 2); assert.equal(staged.storageUpdates.some((update) => update.storage.key === 'mhsmtp-shared-key'), false); assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 1); assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 0); const rollbackQuarantine = await quarantineMaildirMailbox({ root, address: source.address, mailboxId: source.id }); assert.equal(rollbackQuarantine.moved, true); assert.match(rollbackQuarantine.quarantineRelpath, /^\.mailhub-quarantine\//); assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 0); assert.equal(await restoreQuarantinedMaildirMailbox({ root, quarantine: rollbackQuarantine }), true); assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 1); await publishInboundMailboxMaildirTransfer({ root, targetMailbox: prepared.targetMailbox, stagedMessages: staged.stagedMessages }); assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 2); const retainedQuarantine = await quarantineMaildirMailbox({ root, address: source.address, mailboxId: source.id }); assert.equal(retainedQuarantine.moved, true); const result = deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id, { storageUpdates: staged.storageUpdates }); assert.equal(result.migratedMessageCount, 2); const targetEntries = await scanMaildirMailbox({ root, address: target.address }); assert.equal(targetEntries.length, 2); assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 0); const archive = targetEntries.find((entry) => entry.folder === 'Archive'); assert.ok(archive); assert.deepEqual((await readMaildirMessage(archive)).bytes, sourceRaw); assert.deepEqual(archive.flags.sort(), ['\\Flagged', '\\Seen']); assert.deepEqual(archive.keywords, ['keep-me']); const rollbackSource = createInboundMailbox(owner.id, { address: 'rollback@maildir-delete.example' }); const rollbackRaw = Buffer.from('Subject: Rollback\r\n\r\nRollback body'); const rollbackStorage = await writeMaildirMessage({ root, address: rollbackSource.address, rawMessageBytes: rollbackRaw, storageKey: 'mhsmtp-rollback-source' }); createImportedInboundMessage(rollbackSource, { importSource: 'mailbox-delete-test', sourceKey: 'rollback-message', receivedAt: '2026-01-02T03:04:05.000Z', rawMessageBytes: rollbackRaw, storage: rollbackStorage }); const rollbackPrepared = prepareInboundMailboxDeletion(owner.id, rollbackSource.id, target.id, { confirmAddress: rollbackSource.address }); const rollbackStage = await stageInboundMailboxMaildirTransfer({ root, sourceMailbox: rollbackPrepared.sourceMailbox, targetMailbox: rollbackPrepared.targetMailbox, messages: listInboundMailboxTransferMessages(owner.id, rollbackSource.id), reservedStorageKeys: targetEntries.map((entry) => entry.storageKey) }); assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 2); await publishInboundMailboxMaildirTransfer({ root, targetMailbox: rollbackPrepared.targetMailbox, stagedMessages: rollbackStage.stagedMessages }); assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 3); const failedQuarantine = await quarantineMaildirMailbox({ root, address: rollbackSource.address, mailboxId: rollbackSource.id }); assertDeletionCode( () => deleteInboundMailboxWithMessageTransfer(owner.id, rollbackSource.id, target.id, { storageUpdates: [] }), 'INBOUND_MAILBOX_DELETE_MESSAGE_CONFLICT' ); assert.equal(await restoreQuarantinedMaildirMailbox({ root, quarantine: failedQuarantine }), true); await rollbackInboundMailboxMaildirTransfer({ root, targetMailbox: rollbackPrepared.targetMailbox, stagedMessages: rollbackStage.stagedMessages, published: true }); assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 2); restorePreparedInboundMailboxDeletion(owner.id, rollbackSource.id, rollbackPrepared.originalSourceStatus); assert.equal((await scanMaildirMailbox({ root, address: rollbackSource.address })).length, 1); }); test('quarantined Maildir is read-only until rollback restores mailbox permissions', async () => { const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-freeze-')); const root = path.join(dataDir, 'maildir'); initDatabase(dataDir, 'mailbox-delete-freeze-secret'); const owner = createUser({ username: 'freeze-owner', email: 'freeze-owner@example.com', password: 'password123' }); createTestDomain(owner.id, 'freeze.example'); const source = createInboundMailbox(owner.id, { address: 'source@freeze.example' }); await writeMaildirMessage({ root, address: source.address, rawMessageBytes: Buffer.from('Subject: Freeze\r\n\r\nBody'), storageKey: 'mhsmtp-freeze' }); const quarantine = await quarantineMaildirMailbox({ root, address: source.address, mailboxId: source.id, jobId: '30000000000000000000000000000001' }); const snapshot = await snapshotQuarantinedMaildirMailbox({ root, quarantine }); const entry = snapshot.entries[0]; assert.ok(entry); assert.equal(await assertQuarantinedMaildirMailboxUnchanged({ root, quarantine, snapshot }), true); assert.equal((await statFile(entry.filePath)).mode & 0o777, 0o400); assert.equal((await statFile(path.dirname(entry.filePath))).mode & 0o777, 0o500); if (typeof process.getuid !== 'function' || process.getuid() !== 0) { await assert.rejects(writeFile(entry.filePath, Buffer.from('changed')), (error) => ( ['EACCES', 'EPERM'].includes(error?.code) )); await assert.rejects(renameFile(entry.filePath, `${entry.filePath}.renamed`), (error) => ( ['EACCES', 'EPERM'].includes(error?.code) )); } await chmod(entry.filePath, 0o600); await writeFile(entry.filePath, Buffer.from('Subject: Freeze changed\r\n\r\nBody')); await chmod(entry.filePath, 0o400); await assert.rejects( assertQuarantinedMaildirMailboxUnchanged({ root, quarantine, snapshot }), /已发生变化/ ); assert.equal(await restoreQuarantinedMaildirMailbox({ root, quarantine }), true); const restored = (await scanMaildirMailbox({ root, address: source.address }))[0]; assert.equal((await statFile(restored.filePath)).mode & 0o777, 0o600); assert.equal((await statFile(path.dirname(restored.filePath))).mode & 0o777, 0o700); await writeMaildirMessage({ root, address: source.address, rawMessageBytes: Buffer.from('Subject: Writable again\r\n\r\nBody'), storageKey: 'mhsmtp-writable-again' }); assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 2); }); test('published target transfer follows IMAP relpaths and rejects missing files before database commit', async () => { const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-target-verify-')); const root = path.join(dataDir, 'maildir'); initDatabase(dataDir, 'mailbox-delete-target-verify-secret'); const owner = createUser({ username: 'target-verify-owner', email: 'target-verify-owner@example.com', password: 'password123' }); createTestDomain(owner.id, 'target-verify.example'); const source = createInboundMailbox(owner.id, { address: 'source@target-verify.example' }); const target = createInboundMailbox(owner.id, { address: 'target@target-verify.example' }); const raw = Buffer.from('Subject: Target verify\r\n\r\nBody'); const storage = await writeMaildirMessage({ root, address: source.address, rawMessageBytes: raw, storageKey: 'mhsmtp-target-verify' }); createImportedInboundMessage(source, { importSource: 'mailbox-delete-target-verify', sourceKey: 'message', rawMessageBytes: raw, receivedAt: '2026-01-02T03:04:05.000Z', storage }); const jobId = '30000000000000000000000000000002'; const staged = await stageInboundMailboxMaildirTransfer({ root, sourceMailbox: source, targetMailbox: target, messages: listInboundMailboxTransferMessages(owner.id, source.id), jobId }); await publishInboundMailboxMaildirTransfer({ root, targetMailbox: target, stagedMessages: staged.stagedMessages }); const published = (await scanMaildirMailbox({ root, address: target.address }))[0]; const renamedPath = path.join(path.dirname(path.dirname(published.filePath)), 'cur', `${path.basename(published.filePath)}:2,S`); await renameFile(published.filePath, renamedPath); const verified = await verifyPublishedInboundMailboxMaildirTransfer({ root, targetMailbox: target, storageUpdates: staged.storageUpdates, jobId }); assert.equal(verified.length, 1); assert.match(verified[0].storage.relpath, /\/cur\//); await unlink(renamedPath); await assert.rejects( verifyPublishedInboundMailboxMaildirTransfer({ root, targetMailbox: target, storageUpdates: staged.storageUpdates, jobId }), (error) => error?.code === 'INBOUND_MAILBOX_DELETE_MESSAGE_CONFLICT' ); }); test('HTTP mailbox deletion freezes source before publish and verifies target immediately before commit', () => { const serverSource = readFileSync(new URL('../src/server.js', import.meta.url), 'utf8'); const routeStart = serverSource.indexOf("if (inboundMailboxMatch && method === 'DELETE')"); const routeEnd = serverSource.indexOf("if (method === 'GET' && pathname === '/api/inbound-messages')", routeStart); const route = serverSource.slice(routeStart, routeEnd); const sourceVerification = route.indexOf('await assertQuarantinedMaildirMailboxUnchanged'); const targetPublish = route.indexOf('await publishInboundMailboxMaildirTransfer'); const targetVerification = route.indexOf('await verifyPublishedInboundMailboxMaildirTransfer'); const committingPhase = route.indexOf("phase: 'committing_database'"); const databaseCommit = route.indexOf('deleteInboundMailboxWithMessageTransfer'); assert.ok(routeStart >= 0 && routeEnd > routeStart); assert.ok(sourceVerification >= 0 && sourceVerification < targetPublish); assert.ok(targetPublish < targetVerification); assert.ok(targetVerification < committingPhase); assert.ok(committingPhase < databaseCommit); assert.match( route, /restoreError\?\.maildirSourceRenamed === true/ ); }); test('HTTP mutation routes reserve deletion participants before mailbox-scoped writes', () => { const serverSource = readFileSync(new URL('../src/server.js', import.meta.url), 'utf8'); const messageRouteStart = serverSource.indexOf("const inboundMessageMatch = pathname.match"); const messageRouteEnd = serverSource.indexOf("const sendEventMatch = pathname.match", messageRouteStart); const messageRoute = serverSource.slice(messageRouteStart, messageRouteEnd); const lockedMailbox = messageRoute.indexOf('const lockedMailboxId = storage.mailboxId'); const acquireMessageLock = messageRoute.indexOf('await acquireMaildirReconciliationLocks([lockedMailboxId])'); const reloadMessageStorage = messageRoute.indexOf('storage = getInboundMessageMaildirStorage', acquireMessageLock); const verifyLockedMailbox = messageRoute.indexOf('storage.mailboxId !== lockedMailboxId', reloadMessageStorage); const pendingMessageGuard = messageRoute.indexOf('pendingInboundMailboxDeletions.has(storage.mailboxId)', verifyLockedMailbox); const persistedMessageGuard = messageRoute.indexOf('assertInboundMailboxDeletionNotInProgress(storage.mailboxId)', pendingMessageGuard); const mutateMessageFile = messageRoute.indexOf('await setMaildirMessageSeen', persistedMessageGuard); const recordMessageStorage = messageRoute.indexOf('recordInboundMessageMaildirStorage', mutateMessageFile); assert.ok(messageRouteStart >= 0 && messageRouteEnd > messageRouteStart); assert.ok(lockedMailbox >= 0 && lockedMailbox < acquireMessageLock); assert.ok(acquireMessageLock < reloadMessageStorage); assert.ok(reloadMessageStorage < verifyLockedMailbox); assert.ok(verifyLockedMailbox < pendingMessageGuard); assert.ok(pendingMessageGuard < persistedMessageGuard); assert.ok(persistedMessageGuard < mutateMessageFile); assert.ok(mutateMessageFile < recordMessageStorage); assert.equal((messageRoute.match(/pendingInboundMailboxDeletions\.has\(storage\.mailboxId\)/g) || []).length, 1); const webhookCreateStart = serverSource.indexOf("if (method === 'POST' && pathname === '/api/webhooks')"); const webhookCreateEnd = serverSource.indexOf('const webhookMatch = pathname.match', webhookCreateStart); const webhookCreateRoute = serverSource.slice(webhookCreateStart, webhookCreateEnd); assert.ok(webhookCreateStart >= 0 && webhookCreateEnd > webhookCreateStart); assert.ok(webhookCreateRoute.indexOf('await assertSafeWebhookUrl') < webhookCreateRoute.indexOf('const requestedMailbox =')); assert.ok(webhookCreateRoute.indexOf('const requestedMailbox =') < webhookCreateRoute.indexOf('hasPendingInboundMailboxDeletion([requestedMailbox.id])')); assert.ok(webhookCreateRoute.indexOf('hasPendingInboundMailboxDeletion([requestedMailbox.id])') < webhookCreateRoute.indexOf('createWebhook(user.id')); const webhookUpdateStart = serverSource.indexOf("if (method === 'PATCH' && !action)", webhookCreateEnd); const webhookUpdateEnd = serverSource.indexOf("if (method === 'DELETE' && !action)", webhookUpdateStart); const webhookUpdateRoute = serverSource.slice(webhookUpdateStart, webhookUpdateEnd); assert.ok(webhookUpdateStart >= 0 && webhookUpdateEnd > webhookUpdateStart); assert.ok(webhookUpdateRoute.indexOf('const currentWebhook = getWebhook') < webhookUpdateRoute.indexOf('hasPendingInboundMailboxDeletion([')); assert.ok(webhookUpdateRoute.indexOf('hasPendingInboundMailboxDeletion([') < webhookUpdateRoute.indexOf('updateWebhook(user.id')); const bulkGrantStart = serverSource.indexOf("if (method === 'POST' && pathname === '/api/admin/inbound-mailboxes/access/bulk')"); const bulkGrantEnd = serverSource.indexOf('const mailboxAccessMatch = pathname.match', bulkGrantStart); const bulkGrantRoute = serverSource.slice(bulkGrantStart, bulkGrantEnd); assert.ok(bulkGrantStart >= 0 && bulkGrantEnd > bulkGrantStart); assert.ok(bulkGrantRoute.indexOf('const body = await readJson(req)') < bulkGrantRoute.indexOf('hasPendingInboundMailboxDeletion(')); assert.ok(bulkGrantRoute.indexOf('hasPendingInboundMailboxDeletion(') < bulkGrantRoute.indexOf('bulkUpdateInboundMailboxGrants')); const singleGrantStart = serverSource.indexOf("if (mailboxAccessMatch && (method === 'PUT' || method === 'PATCH'))"); const singleGrantEnd = serverSource.indexOf('const transferDomainMatch = pathname.match', singleGrantStart); const singleGrantRoute = serverSource.slice(singleGrantStart, singleGrantEnd); assert.ok(singleGrantStart >= 0 && singleGrantEnd > singleGrantStart); assert.ok(singleGrantRoute.indexOf('const body = await readJson(req)') < singleGrantRoute.indexOf('hasPendingInboundMailboxDeletion([mailboxId])')); assert.ok(singleGrantRoute.indexOf('hasPendingInboundMailboxDeletion([mailboxId])') < singleGrantRoute.indexOf('replaceInboundMailboxGrants')); }); test('interrupted mailbox deletion jobs recover every filesystem phase idempotently', async (t) => { const phases = ['prepared', 'source_quarantined', 'target_staged', 'target_published', 'db_committed']; for (const [phaseIndex, phase] of phases.entries()) { await t.test(phase, async () => { const dataDir = mkdtempSync(path.join(tmpdir(), `mailhub-mailbox-delete-recovery-${phase}-`)); const root = path.join(dataDir, 'maildir'); initDatabase(dataDir, `mailbox-delete-recovery-${phase}-secret`); const owner = createUser({ username: `recovery-owner-${phaseIndex}`, email: `recovery-owner-${phaseIndex}@example.com`, password: 'password123' }); createTestDomain(owner.id, `recovery-${phaseIndex}.example`); const source = createInboundMailbox(owner.id, { address: `source@recovery-${phaseIndex}.example` }); const target = createInboundMailbox(owner.id, { address: `target@recovery-${phaseIndex}.example` }); const raw = Buffer.from(`Subject: Recovery ${phase}\r\n\r\nBody`); const sourceStorage = await writeMaildirMessage({ root, address: source.address, rawMessageBytes: raw, storageKey: `mhsmtp-recovery-${phaseIndex}` }); createImportedInboundMessage(source, { importSource: 'mailbox-delete-recovery-test', sourceKey: phase, rawMessageBytes: raw, receivedAt: '2026-01-02T03:04:05.000Z', storage: sourceStorage }); const jobId = (phaseIndex + 1).toString(16).padStart(32, '0'); const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: source.address, jobId }); let quarantine = null; let staged = null; if (phase !== 'prepared') { const quarantineRelpath = `.mailhub-quarantine/delete-${jobId}`; updateInboundMailboxDeletionJob(jobId, { phase: 'quarantining_source', quarantineRelpath }); quarantine = await quarantineMaildirMailbox({ root, address: source.address, mailboxId: source.id, jobId }); updateInboundMailboxDeletionJob(jobId, { phase: 'source_quarantined', quarantineRelpath: quarantine.quarantineRelpath, quarantineMoved: quarantine.moved }); } if (['target_staged', 'target_published', 'db_committed'].includes(phase)) { const snapshot = await snapshotQuarantinedMaildirMailbox({ root, quarantine }); staged = await stageInboundMailboxMaildirTransfer({ root, sourceMailbox: prepared.sourceMailbox, targetMailbox: prepared.targetMailbox, messages: listInboundMailboxTransferMessages(owner.id, source.id), sourceEntries: snapshot.entries, jobId }); updateInboundMailboxDeletionJob(jobId, { phase: 'target_staged', targetArtifacts: staged.stagedMessages.map((message, index) => ({ messageId: staged.storageUpdates[index].messageId, storageKey: message.storage.key, storageRelpath: message.storage.relpath, stagingRelpath: message.stagingRelpath })) }); } if (['target_published', 'db_committed'].includes(phase)) { await publishInboundMailboxMaildirTransfer({ root, targetMailbox: prepared.targetMailbox, stagedMessages: staged.stagedMessages }); updateInboundMailboxDeletionJob(jobId, { phase: 'target_published' }); } if (phase === 'db_committed') { updateInboundMailboxDeletionJob(jobId, { phase: 'committing_database' }); deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id, { storageUpdates: staged.storageUpdates, deletionJobId: jobId }); } assert.equal(listPendingInboundMailboxDeletionJobs().length, 1); assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: true, logger: {} }), 1); assert.equal(listPendingInboundMailboxDeletionJobs().length, 0); assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: true, logger: {} }), 0); if (phase === 'db_committed') { assert.equal(getInboundMailbox(source.id, owner.id), null); assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 1); } else { assert.equal(getInboundMailboxByAddress(source.address).status, 'active'); assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 1); assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 0); } }); } }); test('mailbox deletion recovery distinguishes planned, absent, restored, and lost quarantine states', async (t) => { await t.test('planned quarantine with no source Maildir restores database state', async () => { const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-planned-')); const root = path.join(dataDir, 'maildir'); initDatabase(dataDir, 'mailbox-delete-planned-secret'); const owner = createUser({ username: 'planned-owner', email: 'planned-owner@example.com', password: 'password123' }); createTestDomain(owner.id, 'planned.example'); const source = createInboundMailbox(owner.id, { address: 'source@planned.example' }); const jobId = '20000000000000000000000000000001'; prepareInboundMailboxDeletion(owner.id, source.id, null, { confirmAddress: source.address, jobId }); updateInboundMailboxDeletionJob(jobId, { phase: 'quarantine_planned', quarantineRelpath: `.mailhub-quarantine/delete-${jobId}` }); assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }), 1); assert.equal(getInboundMailboxByAddress(source.address).status, 'active'); }); await t.test('known absent source Maildir removes tombstone and restores database state', async () => { const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-absent-')); const root = path.join(dataDir, 'maildir'); initDatabase(dataDir, 'mailbox-delete-absent-secret'); const owner = createUser({ username: 'absent-owner', email: 'absent-owner@example.com', password: 'password123' }); createTestDomain(owner.id, 'absent.example'); const source = createInboundMailbox(owner.id, { address: 'source@absent.example' }); const jobId = '20000000000000000000000000000002'; prepareInboundMailboxDeletion(owner.id, source.id, null, { confirmAddress: source.address, jobId }); const quarantineRelpath = `.mailhub-quarantine/delete-${jobId}`; updateInboundMailboxDeletionJob(jobId, { phase: 'quarantine_planned', quarantineRelpath }); const quarantine = await quarantineMaildirMailbox({ root, address: source.address, mailboxId: source.id, jobId, beforeQuarantine: ({ moved }) => updateInboundMailboxDeletionJob(jobId, { phase: 'quarantining_source', quarantineMoved: moved }) }); assert.equal(quarantine.moved, false); updateInboundMailboxDeletionJob(jobId, { phase: 'source_quarantined', quarantineRelpath: quarantine.quarantineRelpath, quarantineMoved: false }); assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }), 1); assert.equal(getInboundMailboxByAddress(source.address).status, 'active'); }); await t.test('recovery is idempotent when rename succeeds before restore completion', async () => { const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-restored-')); const root = path.join(dataDir, 'maildir'); initDatabase(dataDir, 'mailbox-delete-restored-secret'); const owner = createUser({ username: 'restored-owner', email: 'restored-owner@example.com', password: 'password123' }); createTestDomain(owner.id, 'restored.example'); const source = createInboundMailbox(owner.id, { address: 'source@restored.example' }); await writeMaildirMessage({ root, address: source.address, rawMessageBytes: Buffer.from('Subject: Restored\r\n\r\nBody'), storageKey: 'mhsmtp-restored' }); const jobId = '20000000000000000000000000000003'; prepareInboundMailboxDeletion(owner.id, source.id, null, { confirmAddress: source.address, jobId }); const quarantine = await quarantineMaildirMailbox({ root, address: source.address, mailboxId: source.id, jobId, beforeQuarantine: ({ moved, quarantineRelpath }) => updateInboundMailboxDeletionJob(jobId, { phase: 'quarantining_source', quarantineRelpath, quarantineMoved: moved }) }); updateInboundMailboxDeletionJob(jobId, { phase: 'source_quarantined', quarantineRelpath: quarantine.quarantineRelpath, quarantineMoved: true }); updateInboundMailboxDeletionJob(jobId, { phase: 'restoring_source' }); await assert.rejects( restoreQuarantinedMaildirMailbox({ root, quarantine, flush: async () => { throw new Error('simulated restore flush failure'); } }), (error) => error?.maildirSourceRenamed === true ); assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }), 1); assert.equal(getInboundMailboxByAddress(source.address).status, 'active'); assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 1); }); await t.test('recorded moved quarantine missing outside restore phase fails closed', async () => { const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-lost-')); const root = path.join(dataDir, 'maildir'); initDatabase(dataDir, 'mailbox-delete-lost-secret'); const owner = createUser({ username: 'lost-owner', email: 'lost-owner@example.com', password: 'password123' }); createTestDomain(owner.id, 'lost.example'); const source = createInboundMailbox(owner.id, { address: 'source@lost.example' }); await writeMaildirMessage({ root, address: source.address, rawMessageBytes: Buffer.from('Subject: Lost\r\n\r\nBody'), storageKey: 'mhsmtp-lost' }); const jobId = '20000000000000000000000000000004'; prepareInboundMailboxDeletion(owner.id, source.id, null, { confirmAddress: source.address, jobId }); const quarantine = await quarantineMaildirMailbox({ root, address: source.address, mailboxId: source.id, jobId, beforeQuarantine: ({ moved, quarantineRelpath }) => updateInboundMailboxDeletionJob(jobId, { phase: 'quarantining_source', quarantineRelpath, quarantineMoved: moved }) }); updateInboundMailboxDeletionJob(jobId, { phase: 'source_quarantined', quarantineRelpath: quarantine.quarantineRelpath, quarantineMoved: true }); assert.equal(await restoreQuarantinedMaildirMailbox({ root, quarantine }), true); updateInboundMailboxDeletionJob(jobId, { phase: 'rollback_failed' }); await assert.rejects( recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }), /隔离目录缺失/ ); assert.equal(getInboundMailboxByAddress(source.address), null); assert.equal(listPendingInboundMailboxDeletionJobs()[0].phase, 'recovery_failed'); await assert.rejects( recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }), /隔离目录缺失/ ); }); }); function createTestDomain(userId, domain) { return createDomain(userId, { domain, selector: 'mh', verificationToken: `verify-${domain}`, dkimPublic: 'public', dkimPrivate: 'private', senderHost: `mail.${domain}`, sendingIp: '192.0.2.50', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); } function assertDeletionCode(callback, code) { assert.throws(callback, (error) => { assert.equal(error.code, code); return true; }); }