import assert from 'node:assert/strict'; import { mkdirSync, mkdtempSync, renameSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { createVestaMessageSourceKey, decodeModifiedUtf7, importVestaSnapshot, iterateVestaMaildirMessages, parseVestaConfigLine, readVestaSnapshotMetadata } from '../src/vesta-maildir-import.js'; test('Vesta metadata reader safely parses domains, accounts and legacy hashes', async () => { const fixture = createSnapshotFixture(); const snapshot = await readVestaSnapshotMetadata({ root: fixture.root }); assert.equal(snapshot.domains.length, 2); assert.deepEqual(snapshot.domains[0], { source: 'vesta', vestaUser: 'admin', name: 'example.com', domain: 'example.com', catchAll: 'alice@example.com', catchAllAddress: 'alice@example.com', suspended: false, status: 'active', config: { DOMAIN: 'example.com', CATCHALL: 'alice', SUSPENDED: 'no' }, sourceFile: path.join(fixture.root, 'usr/local/vesta/data/users/admin/mail.conf') }); assert.equal(snapshot.mailboxes.length, 1); assert.equal(snapshot.domains[1].catchAll, ''); assert.match(snapshot.mailboxes[0].legacyPasswordHash, /^\{MD5\}\$1\$/); assert.equal(snapshot.mailboxes[0].passwordScheme, 'md5-crypt'); assert.deepEqual(snapshot.mailboxes[0].aliases, ['sales@example.com', 'help@example.com']); assert.deepEqual(snapshot.mailboxes[0].forwardTo, ['archive@elsewhere.test']); assert.equal(snapshot.mailboxes[0].keepForwarded, true); assert.equal(snapshot.mailboxes[0].quotaMb, null); assert.equal(snapshot.mailboxes[0].maildirPath, fixture.maildir); assert.ok(snapshot.mailboxes[0].folders.includes('Empty')); }); test('Maildir reader preserves folders, raw bytes, mtime, flags and keywords', async () => { const fixture = createSnapshotFixture(); const snapshot = await readVestaSnapshotMetadata({ root: fixture.root }); const messages = []; for await (const message of iterateVestaMaildirMessages(snapshot.mailboxes[0])) messages.push(message); assert.equal(messages.length, 3); const inbox = messages.find((message) => message.folder === 'INBOX'); assert.deepEqual(inbox.flags, ['\\Answered', '\\Seen', 'X']); assert.deepEqual(inbox.keywords, ['$Label1', 'b']); assert.equal(inbox.maildirFlags, 'RSXab'); assert.equal(inbox.modifiedAt, '2024-01-02T03:04:05.000Z'); assert.ok(inbox.rawMessageBytes.equals(fixture.inboxBytes)); const sent = messages.find((message) => message.folder === 'Sent'); assert.equal(sent.maildirFlags, 'S'); const chinese = messages.find((message) => message.folder === '中文'); assert.ok(chinese); assert.match(chinese.sourcePath, /^\.&Ti1lhw-\/new\//); const originalKey = inbox.sourceKey; const renamed = path.join(fixture.maildir, 'new', '1700000000.M1P1.host:2,S'); renameSync(fixture.inboxPath, renamed); const renamedBytesHash = inbox.contentSha256; assert.equal(originalKey, createVestaMessageSourceKey({ address: 'alice@example.com', folder: 'INBOX', fileName: path.basename(renamed), contentSha256: renamedBytesHash })); }); test('importVestaSnapshot is dry-run safe, MIME-aware and idempotent by sourceKey', async () => { const fixture = createSnapshotFixture(); const stored = new Map(); const folders = []; const adapter = { async ensureDomain(domain) { return { id: domain.name }; }, async ensureMailbox(mailbox) { return { id: mailbox.address }; }, async ensureFolder(_mailbox, folder) { folders.push(folder); }, async hasMessage(sourceKey) { return stored.has(sourceKey); }, async createMessage(message) { stored.set(message.sourceKey, message); } }; const checkpoints = []; const first = await importVestaSnapshot({ root: fixture.root, adapter, onCheckpoint: (sourceKey) => checkpoints.push(sourceKey) }); assert.equal(first.messages, 3); assert.equal(first.importedMessages, 3); assert.equal(first.skippedMessages, 0); assert.equal(first.plannedMessages, 0); assert.equal(stored.size, 3); assert.ok(folders.includes('Empty')); assert.equal(checkpoints.at(-1), first.lastSourceKey); assert.ok([...stored.values()].every((message) => Buffer.isBuffer(message.rawMessageBytes))); const inbox = [...stored.values()].find((message) => message.folder === 'INBOX'); assert.equal(inbox.read, true); assert.equal(inbox.receivedAt, '2024-01-02T03:04:05.000Z'); const chinese = [...stored.values()].find((message) => message.folder === '中文'); assert.equal(chinese.messageId, ''); assert.equal(chinese.subject, 'No message id'); const sent = [...stored.values()].find((message) => message.folder === 'Sent'); assert.deepEqual(sent.recipients, ['bob@example.net']); const second = await importVestaSnapshot({ root: fixture.root, adapter }); assert.equal(second.importedMessages, 0); assert.equal(second.skippedMessages, 3); assert.equal(stored.size, 3); const neverWrite = new Proxy({}, { get() { return () => { throw new Error('dry-run called adapter'); }; } }); const dryRun = await importVestaSnapshot({ root: fixture.root, adapter: neverWrite, dryRun: true }); assert.equal(dryRun.importedMessages, 0); assert.equal(dryRun.plannedMessages, 3); assert.equal(dryRun.messages, 3); const orderedMessages = []; const snapshot = await readVestaSnapshotMetadata({ root: fixture.root }); for await (const message of iterateVestaMaildirMessages(snapshot.mailboxes[0])) orderedMessages.push(message); const resumed = await importVestaSnapshot({ root: fixture.root, dryRun: true, resumeAfterSourceKey: orderedMessages[0].sourceKey }); assert.equal(resumed.resumeSkippedMessages, 1); assert.equal(resumed.plannedMessages, 2); await assert.rejects( importVestaSnapshot({ root: fixture.root, dryRun: true, resumeAfterSourceKey: 'missing' }), /断点不存在/ ); }); test('malformed MIME is imported as raw mail and concurrent deduplication is counted as skipped', async () => { const fixture = createSnapshotFixture(); const rawMessages = []; const result = await importVestaSnapshot({ root: fixture.root, parseMessage: async () => { throw new Error('malformed MIME'); }, adapter: { async ensureDomain(domain) { return domain; }, async ensureMailbox(mailbox) { return mailbox; }, async hasMessage() { return false; }, async createMessage(message) { rawMessages.push(message.rawMessageBytes); return { created: false }; } } }); assert.equal(result.importedMessages, 0); assert.equal(result.skippedMessages, 3); assert.equal(result.warningCount, result.warnings.length + 3); assert.equal(rawMessages.length, 3); assert.ok(rawMessages.every(Buffer.isBuffer)); }); test('config and Modified UTF-7 parsing do not execute shell syntax', () => { assert.deepEqual( parseVestaConfigLine("ACCOUNT='alice' FWD='archive@example.net' SUSPENDED='no'"), { ACCOUNT: 'alice', FWD: 'archive@example.net', SUSPENDED: 'no' } ); assert.equal(decodeModifiedUtf7('&Ti1lhw-'), '中文'); assert.equal(decodeModifiedUtf7('R&-D &- QA'), 'R&D & QA'); }); function createSnapshotFixture() { const root = mkdtempSync(path.join(tmpdir(), 'mailhub-vesta-snapshot-')); const userData = path.join(root, 'usr/local/vesta/data/users/admin'); const domainConfig = path.join(userData, 'mail'); const confMail = path.join(root, 'home/admin/conf/mail/example.com'); const maildir = path.join(root, 'home/admin/mail/example.com/alice'); for (const directory of [ domainConfig, confMail, path.join(maildir, 'cur'), path.join(maildir, 'new'), path.join(maildir, 'tmp'), path.join(maildir, '.Sent/cur'), path.join(maildir, '.Sent/new'), path.join(maildir, '.Empty/cur'), path.join(maildir, '.Empty/new'), path.join(maildir, '.&Ti1lhw-/cur'), path.join(maildir, '.&Ti1lhw-/new') ]) mkdirSync(directory, { recursive: true }); writeFileSync( path.join(userData, 'mail.conf'), "DOMAIN='example.com' CATCHALL='alice' SUSPENDED='no'\n" + "DOMAIN='nocatch.example' CATCHALL='no' SUSPENDED='no'\n" ); const legacyHash = '{MD5}$1$salt1234$abcdefghijklmnopqrstuv'; writeFileSync( path.join(domainConfig, 'example.com.conf'), `ACCOUNT='alice' ALIAS='sales,help' AUTOREPLY='no' FWD='archive@elsewhere.test' FWD_ONLY='no' MD5='${legacyHash}' QUOTA='unlimited' U_DISK='0' SUSPENDED='no' TIME='03:04:05' DATE='2024-01-02'\n` ); writeFileSync( path.join(confMail, 'passwd'), `alice:${legacyHash}:admin:mail::/home/admin:0:userdb_quota_rule=*:storage=0M\n` ); writeFileSync(path.join(maildir, 'dovecot-keywords'), '0 $Label1\n'); const inboxBytes = Buffer.concat([ Buffer.from([ 'From: Bob ', 'To: alice@example.com', 'Subject: Inbox message', 'Message-ID: ', 'Content-Type: text/plain; charset=iso-8859-1', '', 'caf' ].join('\r\n'), 'ascii'), Buffer.from([0xe9, 0x0d, 0x0a]) ]); const inboxPath = path.join(maildir, 'cur', '1700000000.M1P1.host:2,RSXab'); writeFileSync(inboxPath, inboxBytes); const timestamp = new Date('2024-01-02T03:04:05.000Z'); utimesSync(inboxPath, timestamp, timestamp); writeFileSync(path.join(maildir, '.Sent/cur', '1700000001.M2P1.host:2,S'), [ 'From: alice@example.com', 'To: bob@example.net', 'Subject: Sent message', 'Message-ID: ', '', 'Sent body' ].join('\r\n')); writeFileSync(path.join(maildir, '.&Ti1lhw-/new', '1700000002.M3P1.host'), [ 'From: sender@example.net', 'To: alice@example.com', 'Subject: No message id', '', 'Custom folder body' ].join('\r\n')); return { root, maildir, inboxBytes, inboxPath }; }