| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- 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 <bob@example.net>',
- 'To: alice@example.com',
- 'Subject: Inbox message',
- 'Message-ID: <inbox@example.net>',
- '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@example.com>',
- '',
- '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 };
- }
|