|
|
@@ -0,0 +1,1205 @@
|
|
|
+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;
|
|
|
+ });
|
|
|
+}
|