import assert from 'node:assert/strict'; import { mkdtempSync } from 'node:fs'; import { mkdir, rename, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { commitInboundMaildirMigrationStaging, createDomain, createInboundMailbox, createInboundMessage, createUser, createWebhook, getInboundMessage, initDatabase, listInboundMailboxFolders, listInboundMaildirIndex, listInboundMaildirMigrationStaging, listInboundMessages, listWebhookDeliveries, stageInboundMessageMaildirStorageBatch } from '../src/db.js'; import { ensureMaildirMailbox, maildirFolderPath, scanMaildirMailbox, writeMaildirMessage } from '../src/maildir-store.js'; import { migrateInboundMessagesToMaildir, reconcileMaildirMailbox } from '../src/maildir-sync.js'; test('SQLite migration is resumable and Maildir changes reconcile into the management index', async () => { const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-sync-db-')); const maildirRoot = path.join(dataDir, 'maildir'); initDatabase(dataDir, 'maildir-sync-secret'); const user = createUser({ username: 'maildir-sync-user', email: 'maildir-sync-user@example.com', password: 'password123' }); createDomain(user.id, { domain: 'maildir-sync.example', selector: 'mh', verificationToken: 'verify', dkimPublic: 'public', dkimPrivate: 'private', senderHost: 'mail.maildir-sync.example', sendingIp: '192.0.2.70', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); const mailbox = createInboundMailbox(user.id, { address: 'box@maildir-sync.example', password: 'mailbox-pass-123' }); const originalRaw = 'From: alice@example.net\r\nSubject: Existing message\r\n\r\nBody'; const original = createInboundMessage(mailbox, { sender: 'alice@example.net', recipients: [mailbox.address], subject: 'Existing message', rawMessage: originalRaw, textBody: 'Body' }); const interruptedStorage = await writeMaildirMessage({ root: maildirRoot, address: mailbox.address, rawMessageBytes: Buffer.from(originalRaw, 'utf8'), storageKey: `mhdb-${original.id}`, durable: false }); stageInboundMessageMaildirStorageBatch([{ id: original.id, storage: interruptedStorage }]); const first = await migrateInboundMessagesToMaildir({ root: maildirRoot }); assert.deepEqual(first, { processed: 1, written: 1, reused: 0, lastId: original.id }); const migratedFiles = await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address }); assert.equal(migratedFiles.length, 1); assert.equal(migratedFiles[0].storageKey, `mhdb-${original.id}`); assert.equal(listInboundMaildirIndex(mailbox.id)[0].storageKey, `mhdb-${original.id}`); const second = await migrateInboundMessagesToMaildir({ root: maildirRoot }); assert.deepEqual(second, { processed: 0, written: 0, reused: 0, lastId: 0 }); assert.equal((await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address })).length, 1); const archiveCur = path.join(maildirFolderPath(maildirRoot, mailbox.address, 'Archive'), 'cur'); await mkdir(archiveCur, { recursive: true }); const archivedPath = path.join( archiveCur, `${migratedFiles[0].baseName}:2,S` ); await rename(migratedFiles[0].filePath, archivedPath); const missingCounts = new Map(); const moved = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts }); assert.equal(moved.updated, 1); assert.equal(getInboundMessage(user.id, original.id).folder, 'Archive'); assert.equal(getInboundMessage(user.id, original.id).read, true); await writeMaildirMessage({ root: maildirRoot, address: mailbox.address, rawMessageBytes: Buffer.from('From: bob@example.net\r\nSubject: Appended message\r\n\r\nNew body'), folder: 'Sent', read: true, storageKey: 'mhappend-client-1' }); const appended = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts }); assert.equal(appended.created, 1); const messages = listInboundMessages(user.id, { folder: 'Sent' }); assert.equal(messages.length, 1); assert.equal(messages[0].subject, 'Appended message'); await ensureMaildirMailbox(maildirRoot, mailbox.address, ['team.ops']); const folderAdded = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts }); assert.equal(folderAdded.foldersCreatedOrRestored, 1); assert.equal( listInboundMailboxFolders(user.id, mailbox.id).some((folder) => folder.name === 'team.ops'), true ); await rm(maildirFolderPath(maildirRoot, mailbox.address, 'team.ops'), { recursive: true }); const folderRemoved = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts }); assert.equal(folderRemoved.foldersDeleted, 1); assert.equal( listInboundMailboxFolders(user.id, mailbox.id).some((folder) => folder.name === 'team.ops'), false ); const archiveEntry = (await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address })) .find((entry) => entry.storageKey === `mhdb-${original.id}`); await rm(archiveEntry.filePath); const firstMissing = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts }); assert.equal(firstMissing.deleted, 0); const secondMissing = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts }); assert.equal(secondMissing.deleted, 1); assert.equal(getInboundMessage(user.id, original.id), null); }); test('SQLite migration stops when a resumable Maildir key contains different bytes', async () => { const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-conflict-db-')); const maildirRoot = path.join(dataDir, 'maildir'); initDatabase(dataDir, 'maildir-conflict-secret'); const user = createUser({ username: 'maildir-conflict-user', email: 'maildir-conflict-user@example.com', password: 'password123' }); createDomain(user.id, { domain: 'maildir-conflict.example', selector: 'mh', verificationToken: 'verify', dkimPublic: 'public', dkimPrivate: 'private', senderHost: 'mail.maildir-conflict.example', sendingIp: '192.0.2.71', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); const mailbox = createInboundMailbox(user.id, { address: 'box@maildir-conflict.example', password: 'mailbox-pass-123' }); const preparedBeforeFailure = createInboundMessage(mailbox, { sender: 'first@example.net', recipients: [mailbox.address], subject: 'Prepared before conflict', rawMessage: 'From: first@example.net\r\nSubject: Prepared before conflict\r\n\r\nFirst' }); const original = createInboundMessage(mailbox, { sender: 'alice@example.net', recipients: [mailbox.address], subject: 'Original message', rawMessage: 'From: alice@example.net\r\nSubject: Original message\r\n\r\nOriginal' }); await writeMaildirMessage({ root: maildirRoot, address: mailbox.address, rawMessageBytes: Buffer.from('From: mallory@example.net\r\nSubject: Conflict\r\n\r\nConflict'), storageKey: `mhdb-${original.id}` }); await assert.rejects( migrateInboundMessagesToMaildir({ root: maildirRoot }), /存储标识已被其他内容占用/ ); assert.equal(listInboundMaildirIndex(mailbox.id).length, 0); const remaining = await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address }); assert.equal(remaining.some((entry) => entry.storageKey === `mhdb-${preparedBeforeFailure.id}`), false); assert.equal(remaining.some((entry) => entry.storageKey === `mhdb-${original.id}`), true); }); test('Maildir cutover rejects a staging count mismatch before changing the message index', () => { const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-count-db-')); initDatabase(dataDir, 'maildir-count-secret'); const user = createUser({ username: 'maildir-count-user', email: 'maildir-count-user@example.com', password: 'password123' }); createDomain(user.id, { domain: 'maildir-count.example', selector: 'mh', verificationToken: 'verify', dkimPublic: 'public', dkimPrivate: 'private', senderHost: 'mail.maildir-count.example', sendingIp: '192.0.2.72', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); const mailbox = createInboundMailbox(user.id, { address: 'box@maildir-count.example', password: 'mailbox-pass-123' }); const message = createInboundMessage(mailbox, { sender: 'alice@example.net', recipients: [mailbox.address], subject: 'Count guard', rawMessage: 'From: alice@example.net\r\nSubject: Count guard\r\n\r\nBody' }); stageInboundMessageMaildirStorageBatch([{ id: message.id, storage: { backend: 'maildir', key: `mhdb-${message.id}`, relpath: `mail/new/mhdb-${message.id}.mailhub`, sha256: 'a'.repeat(64), size: 4, mtimeMs: Date.now(), indexedAt: new Date().toISOString() } }]); assert.throws( () => commitInboundMaildirMigrationStaging(2), /Maildir 迁移暂存数量不一致/ ); assert.equal(listInboundMaildirIndex(mailbox.id).length, 0); assert.equal(listInboundMaildirMigrationStaging().length, 1); }); test('Maildir reconciliation restores a missing SMTP index and queues its receipt webhook once', async () => { const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-webhook-db-')); const maildirRoot = path.join(dataDir, 'maildir'); const database = initDatabase(dataDir, 'maildir-webhook-secret'); const user = createUser({ username: 'maildir-webhook-user', email: 'maildir-webhook-user@example.com', password: 'password123' }); createDomain(user.id, { domain: 'maildir-webhook.example', selector: 'mh', verificationToken: 'verify', dkimPublic: 'public', dkimPrivate: 'private', senderHost: 'mail.maildir-webhook.example', sendingIp: '192.0.2.73', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); const mailbox = createInboundMailbox(user.id, { address: 'box@maildir-webhook.example', password: 'mailbox-pass-123' }); createWebhook(user.id, { name: 'Recovered SMTP receipt', url: 'https://hooks.example.com/recovered-smtp', events: ['received'], mailboxId: mailbox.id }); await writeMaildirMessage({ root: maildirRoot, address: mailbox.address, rawMessageBytes: Buffer.from([ 'Message-ID: ', 'From: sender@example.net', `To: ${mailbox.address}`, 'Subject: Recovered SMTP receipt', '', 'Recovered body' ].join('\r\n')), storageKey: 'mhsmtp-recovered-1' }); database.exec(` CREATE TRIGGER reject_recovered_smtp_webhook BEFORE INSERT ON webhook_deliveries WHEN NEW.inbound_message_id IS NOT NULL BEGIN SELECT RAISE(ABORT, 'forced webhook failure'); END; `); await assert.rejects( reconcileMaildirMailbox({ root: maildirRoot, mailbox }), /forced webhook failure/ ); assert.equal(listInboundMessages(user.id, { mailboxId: mailbox.id }).length, 0); assert.equal(listWebhookDeliveries(user.id, { eventType: 'received' }).length, 0); database.exec('DROP TRIGGER reject_recovered_smtp_webhook;'); const first = await reconcileMaildirMailbox({ root: maildirRoot, mailbox }); assert.equal(first.created, 1); const [message] = listInboundMessages(user.id, { mailboxId: mailbox.id }); assert.equal(message.subject, 'Recovered SMTP receipt'); let deliveries = listWebhookDeliveries(user.id, { eventType: 'received' }); assert.equal(deliveries.length, 1); assert.equal(deliveries[0].inboundMessageId, message.id); assert.equal(JSON.parse(deliveries[0].payloadJson).type, 'email.received'); const second = await reconcileMaildirMailbox({ root: maildirRoot, mailbox }); assert.equal(second.created, 0); deliveries = listWebhookDeliveries(user.id, { eventType: 'received' }); assert.equal(deliveries.length, 1); });