| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- 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: <recovered-smtp@example.net>',
- '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);
- });
|