| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205 |
- 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;
- });
- }
|