| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583 |
- import { createHash } from 'node:crypto';
- import { opendir, readFile, stat } from 'node:fs/promises';
- import path from 'node:path';
- import { parseInboundMessage } from './inbound-mail.js';
- import { decodeModifiedUtf7 } from './imap-utf7.js';
- export { decodeModifiedUtf7 };
- const standardFolders = new Map([
- ['inbox', 'INBOX'],
- ['sent', 'Sent'],
- ['sent items', 'Sent'],
- ['draft', 'Drafts'],
- ['drafts', 'Drafts'],
- ['trash', 'Trash'],
- ['deleted messages', 'Trash'],
- ['junk', 'Junk'],
- ['junk e-mail', 'Junk'],
- ['spam', 'Junk'],
- ['archive', 'Archive']
- ]);
- const standardMaildirFlags = new Map([
- ['D', '\\Draft'],
- ['F', '\\Flagged'],
- ['P', '\\Passed'],
- ['R', '\\Answered'],
- ['S', '\\Seen'],
- ['T', '\\Deleted']
- ]);
- /**
- * Imports a filesystem snapshot without coupling the reader to MailHub's DB.
- *
- * A writable adapter implements ensureDomain, ensureMailbox, hasMessage and
- * createMessage. hasMessage receives the stable sourceKey and makes reruns and
- * crash recovery idempotent. dryRun never invokes adapter methods.
- */
- export async function importVestaSnapshot({
- root,
- adapter = null,
- dryRun = false,
- vestaUser = '',
- userDataRoot = '',
- homeRoot = '',
- resumeAfterSourceKey = '',
- onCheckpoint = null,
- signal = null,
- parseMessage = parseInboundMessage
- } = {}) {
- if (!dryRun) validateAdapter(adapter);
- const snapshot = await readVestaSnapshotMetadata({ root, vestaUser, userDataRoot, homeRoot });
- const report = {
- dryRun: Boolean(dryRun),
- domains: snapshot.domains.length,
- mailboxes: snapshot.mailboxes.length,
- messages: 0,
- bytes: 0,
- plannedMessages: 0,
- importedMessages: 0,
- skippedMessages: 0,
- resumeSkippedMessages: 0,
- lastSourceKey: '',
- warnings: [...snapshot.warnings],
- warningCount: snapshot.warnings.length
- };
- const domainReferences = new Map();
- const mailboxReferences = new Map();
- const seenSourceKeys = new Set();
- let waitingForCheckpoint = Boolean(resumeAfterSourceKey);
- for (const domain of snapshot.domains) {
- abortIfNeeded(signal);
- const reference = dryRun ? domain : await adapter.ensureDomain(domain);
- domainReferences.set(domainKey(domain), reference ?? domain);
- }
- for (const mailbox of snapshot.mailboxes) {
- abortIfNeeded(signal);
- const domainReference = domainReferences.get(domainKey(mailbox));
- const mailboxReference = dryRun
- ? mailbox
- : await adapter.ensureMailbox(mailbox, { domain: domainReference });
- mailboxReferences.set(mailbox.address, mailboxReference ?? mailbox);
- if (!dryRun && typeof adapter.ensureFolder === 'function') {
- for (const folder of mailbox.folders || []) {
- await adapter.ensureFolder(mailboxReference ?? mailbox, folder, { sourceMailbox: mailbox });
- }
- }
- for await (const sourceMessage of iterateVestaMaildirMessages(mailbox, { signal })) {
- abortIfNeeded(signal);
- report.messages += 1;
- report.bytes += sourceMessage.rawMessageBytes.length;
- if (waitingForCheckpoint) {
- report.resumeSkippedMessages += 1;
- seenSourceKeys.add(sourceMessage.sourceKey);
- if (sourceMessage.sourceKey === resumeAfterSourceKey) {
- waitingForCheckpoint = false;
- report.lastSourceKey = sourceMessage.sourceKey;
- }
- continue;
- }
- if (seenSourceKeys.has(sourceMessage.sourceKey)) {
- report.skippedMessages += 1;
- continue;
- }
- seenSourceKeys.add(sourceMessage.sourceKey);
- const context = {
- domain: domainReference,
- mailbox: mailboxReferences.get(mailbox.address),
- sourceMailbox: mailbox
- };
- if (!dryRun && await adapter.hasMessage(sourceMessage.sourceKey, context)) {
- report.skippedMessages += 1;
- report.lastSourceKey = sourceMessage.sourceKey;
- await onCheckpoint?.(sourceMessage.sourceKey, { ...context, message: sourceMessage });
- continue;
- }
- let parsed = {};
- if (parseMessage) {
- try {
- parsed = await parseMessage(sourceMessage.rawMessageBytes, []);
- } catch {
- // Preserve the original message even when a malformed MIME structure
- // cannot be indexed. Protocol clients can still retrieve it verbatim.
- report.warningCount += 1;
- }
- }
- const message = {
- ...parsed,
- ...sourceMessage,
- recipients: parsed.recipients?.length ? parsed.recipients : [mailbox.address],
- receivedAt: sourceMessage.modifiedAt,
- read: sourceMessage.flags.includes('\\Seen')
- };
- if (dryRun) report.plannedMessages += 1;
- else {
- const result = await adapter.createMessage(message, context);
- if (result?.created === false) report.skippedMessages += 1;
- else report.importedMessages += 1;
- }
- report.lastSourceKey = sourceMessage.sourceKey;
- if (!dryRun) await onCheckpoint?.(sourceMessage.sourceKey, { ...context, message });
- }
- }
- if (waitingForCheckpoint) {
- throw new Error(`Vesta 导入断点不存在:${resumeAfterSourceKey}`);
- }
- return report;
- }
- export async function readVestaSnapshotMetadata({
- root,
- vestaUser = '',
- userDataRoot = '',
- homeRoot = ''
- } = {}) {
- const snapshotRoot = requireSnapshotRoot(root);
- const userLocations = await findVestaUserLocations(snapshotRoot, { vestaUser, userDataRoot });
- if (!userLocations.length) throw new Error('Vesta 快照中未找到 mail.conf。');
- const domains = [];
- const mailboxes = [];
- const warnings = [];
- for (const location of userLocations) {
- const domainRecords = await readConfigRecords(path.join(location.userDataDir, 'mail.conf'));
- const home = await resolveVestaHome(snapshotRoot, location.vestaUser, homeRoot);
- for (const config of domainRecords) {
- if (!config.DOMAIN) continue;
- const name = String(config.DOMAIN).trim().toLowerCase();
- const catchAll = normalizeAddress(config.CATCHALL, name);
- const domain = {
- source: 'vesta',
- vestaUser: location.vestaUser,
- name,
- domain: name,
- catchAll,
- catchAllAddress: catchAll,
- suspended: isYes(config.SUSPENDED),
- status: isYes(config.SUSPENDED) ? 'suspended' : 'active',
- config,
- sourceFile: path.join(location.userDataDir, 'mail.conf')
- };
- domains.push(domain);
- const accountFile = path.join(location.userDataDir, 'mail', `${name}.conf`);
- const accountRecords = await readConfigRecords(accountFile, { optional: true });
- const passwdFile = home.confMailRoot ? path.join(home.confMailRoot, name, 'passwd') : '';
- const passwdRecords = passwdFile
- ? await readVestaPasswd(passwdFile, { optional: true })
- : [];
- const accounts = mergeAccountRecords(accountRecords, passwdRecords);
- if (!accounts.length) warnings.push(`域名 ${name} 没有邮箱账户元数据。`);
- for (const account of accounts) {
- const localPart = String(account.ACCOUNT || account.account || '').trim().toLowerCase();
- if (!localPart) continue;
- const passwordHash = String(account.passwordHash || account.MD5 || '').trim();
- const maildirPath = home.mailRoot
- ? path.join(home.mailRoot, name, localPart)
- : '';
- const address = `${localPart}@${name}`;
- const maildirAvailable = Boolean(maildirPath) && await isDirectory(maildirPath);
- const folders = maildirAvailable
- ? (await listMaildirFolders(maildirPath)).map((folder) => folder.name)
- : [];
- if (!passwordHash) warnings.push(`邮箱 ${address} 没有可迁移密码哈希。`);
- if (!maildirAvailable) warnings.push(`邮箱 ${address} 没有 Maildir,仍会导入账户。`);
- mailboxes.push({
- source: 'vesta',
- vestaUser: location.vestaUser,
- domain: name,
- localPart,
- address,
- displayName: '',
- aliases: normalizeAddressList(account.ALIAS, name),
- forwardTo: normalizeAddressList(account.FWD),
- forwardOnly: isYes(account.FWD_ONLY),
- keepForwarded: !isYes(account.FWD_ONLY),
- quotaMb: normalizeQuota(account.QUOTA ?? account.quota),
- suspended: isYes(account.SUSPENDED),
- status: isYes(account.SUSPENDED) ? 'suspended' : 'active',
- legacyPasswordHash: passwordHash,
- passwordHash,
- passwordScheme: passwordScheme(passwordHash),
- createdAt: vestaTimestamp(account.DATE, account.TIME),
- maildirPath,
- maildirAvailable,
- folders,
- config: account,
- sourceFile: accountFile,
- passwordSourceFile: account.passwordSourceFile || ''
- });
- }
- }
- }
- domains.sort((left, right) => compareText(domainKey(left), domainKey(right)));
- mailboxes.sort((left, right) => compareText(left.address, right.address));
- return { root: snapshotRoot, domains, mailboxes, warnings };
- }
- export async function* iterateVestaMaildirMessages(mailbox, { signal = null } = {}) {
- if (!mailbox?.maildirPath || !await isDirectory(mailbox.maildirPath)) return;
- const folders = await listMaildirFolders(mailbox.maildirPath);
- for (const folder of folders) {
- const keywordMap = await readDovecotKeywords(folder.path, mailbox.maildirPath);
- for (const bucket of ['new', 'cur']) {
- const directory = path.join(folder.path, bucket);
- const entries = await readDirectoryEntries(directory);
- for (const entry of entries.filter((item) => item.isFile()).sort(compareDirents)) {
- abortIfNeeded(signal);
- const filePath = path.join(directory, entry.name);
- const [rawMessageBytes, fileStat] = await Promise.all([readFile(filePath), stat(filePath)]);
- const flagInfo = parseMaildirFlags(entry.name, keywordMap);
- const contentSha256 = createHash('sha256').update(rawMessageBytes).digest('hex');
- const sourceKey = createVestaMessageSourceKey({
- address: mailbox.address,
- folder: folder.name,
- fileName: entry.name,
- contentSha256
- });
- yield {
- source: 'vesta-maildir',
- sourceKey,
- sourcePath: path.relative(mailbox.maildirPath, filePath).split(path.sep).join('/'),
- fileName: entry.name,
- folder: folder.name,
- rawMessageBytes,
- contentSha256,
- modifiedAt: fileStat.mtime.toISOString(),
- size: fileStat.size,
- flags: flagInfo.flags,
- keywords: flagInfo.keywords,
- maildirFlags: flagInfo.raw
- };
- }
- }
- }
- }
- export function createVestaMessageSourceKey({ address, folder, fileName, contentSha256 }) {
- const stableFileName = String(fileName || '').replace(/:2,[^/]*$/, '');
- const source = [
- 'vesta-maildir-v1',
- String(address || '').trim().toLowerCase(),
- normalizeFolder(folder),
- stableFileName,
- String(contentSha256 || '').toLowerCase()
- ].join('\0');
- return `vesta-maildir-v1:${createHash('sha256').update(source).digest('hex')}`;
- }
- export function parseVestaConfigLine(line) {
- const source = String(line || '');
- const output = {};
- let index = 0;
- while (index < source.length) {
- while (/\s/.test(source[index] || '')) index += 1;
- if (!source[index] || source[index] === '#') break;
- const keyMatch = source.slice(index).match(/^([A-Za-z_][A-Za-z0-9_]*)=/);
- if (!keyMatch) {
- while (source[index] && !/\s/.test(source[index])) index += 1;
- continue;
- }
- const key = keyMatch[1];
- index += keyMatch[0].length;
- let value = '';
- const quote = source[index] === "'" || source[index] === '"' ? source[index++] : '';
- if (quote) {
- while (index < source.length && source[index] !== quote) {
- if (quote === '"' && source[index] === '\\' && index + 1 < source.length) index += 1;
- value += source[index++];
- }
- if (source[index] === quote) index += 1;
- } else {
- while (source[index] && !/\s/.test(source[index])) value += source[index++];
- }
- output[key] = value;
- }
- return output;
- }
- export async function readVestaPasswd(filePath, { optional = false } = {}) {
- const content = await readTextFile(filePath, { optional });
- if (content === null) return [];
- return content.split(/\r?\n/).map((line) => {
- const clean = line.trim();
- if (!clean || clean.startsWith('#')) return null;
- const fields = clean.split(':');
- if (!fields[0] || !fields[1]) return null;
- return {
- account: fields[0].trim().toLowerCase(),
- passwordHash: fields[1].trim(),
- user: fields[2] || '',
- group: fields[3] || '',
- home: fields[5] || '',
- quota: fields[6] || '',
- passwordSourceFile: filePath
- };
- }).filter(Boolean);
- }
- function validateAdapter(adapter) {
- for (const method of ['ensureDomain', 'ensureMailbox', 'hasMessage', 'createMessage']) {
- if (typeof adapter?.[method] !== 'function') throw new TypeError(`Vesta 导入 adapter 缺少 ${method}()。`);
- }
- }
- async function findVestaUserLocations(root, { vestaUser, userDataRoot }) {
- const locations = [];
- const explicitRoot = userDataRoot ? path.resolve(userDataRoot) : '';
- const bases = explicitRoot
- ? [explicitRoot]
- : [
- path.join(root, 'usr/local/vesta/data/users'),
- path.join(root, 'usr/local/hestia/data/users'),
- path.join(root, 'vesta/data/users'),
- path.join(root, 'data/users'),
- path.join(root, 'users')
- ];
- if (await isFile(path.join(root, 'mail.conf'))) {
- locations.push({ vestaUser: vestaUser || path.basename(root), userDataDir: root });
- }
- for (const base of bases) {
- if (await isFile(path.join(base, 'mail.conf'))) {
- const user = vestaUser || path.basename(base);
- locations.push({ vestaUser: user, userDataDir: base });
- continue;
- }
- for (const entry of await readDirectoryEntries(base)) {
- if (!entry.isDirectory() || (vestaUser && entry.name !== vestaUser)) continue;
- const userDataDir = path.join(base, entry.name);
- if (await isFile(path.join(userDataDir, 'mail.conf'))) {
- locations.push({ vestaUser: entry.name, userDataDir });
- }
- }
- }
- const unique = new Map(locations.map((location) => [path.resolve(location.userDataDir), location]));
- return [...unique.values()].sort((left, right) => compareText(left.vestaUser, right.vestaUser));
- }
- async function resolveVestaHome(root, vestaUser, homeRoot) {
- const explicitRoot = homeRoot ? path.resolve(homeRoot) : '';
- const homeCandidates = explicitRoot
- ? [path.join(explicitRoot, vestaUser), explicitRoot]
- : [path.join(root, 'home', vestaUser), path.join(root, vestaUser)];
- for (const homeDirectory of homeCandidates) {
- if (!await isDirectory(homeDirectory)) continue;
- return {
- homeDirectory,
- mailRoot: await isDirectory(path.join(homeDirectory, 'mail')) ? path.join(homeDirectory, 'mail') : '',
- confMailRoot: await isDirectory(path.join(homeDirectory, 'conf/mail')) ? path.join(homeDirectory, 'conf/mail') : ''
- };
- }
- return { homeDirectory: '', mailRoot: '', confMailRoot: '' };
- }
- async function readConfigRecords(filePath, { optional = false } = {}) {
- const content = await readTextFile(filePath, { optional });
- if (content === null) return [];
- return content.split(/\r?\n/)
- .map(parseVestaConfigLine)
- .filter((record) => Object.keys(record).length);
- }
- function mergeAccountRecords(accountRecords, passwdRecords) {
- const records = new Map();
- for (const config of accountRecords) {
- const account = String(config.ACCOUNT || '').trim().toLowerCase();
- if (account) records.set(account, { ...config, ACCOUNT: account });
- }
- for (const passwd of passwdRecords) {
- const previous = records.get(passwd.account) || { ACCOUNT: passwd.account };
- records.set(passwd.account, {
- ...previous,
- passwordHash: passwd.passwordHash || previous.MD5 || '',
- passwordSourceFile: passwd.passwordSourceFile,
- quota: passwd.quota
- });
- }
- return [...records.values()].sort((left, right) => compareText(left.ACCOUNT, right.ACCOUNT));
- }
- async function listMaildirFolders(maildirPath) {
- const folders = [{ name: 'INBOX', path: maildirPath }];
- for (const entry of (await readDirectoryEntries(maildirPath)).sort(compareDirents)) {
- if (!entry.isDirectory() || !entry.name.startsWith('.') || entry.name === '.') continue;
- const folderPath = path.join(maildirPath, entry.name);
- if (!await hasMaildirMessagesDirectory(folderPath)) continue;
- const segments = entry.name.slice(1).split('.').filter(Boolean).map(decodeModifiedUtf7);
- if (segments[0]?.toLowerCase() === 'inbox') segments.shift();
- const name = normalizeFolder(segments.join('/'));
- if (name && name !== 'INBOX') folders.push({ name, path: folderPath });
- }
- return folders.sort((left, right) => compareText(left.name, right.name));
- }
- async function hasMaildirMessagesDirectory(directory) {
- return await isDirectory(path.join(directory, 'cur')) || await isDirectory(path.join(directory, 'new'));
- }
- async function readDovecotKeywords(folderPath, maildirPath) {
- const mapping = new Map();
- for (const filePath of [...new Set([
- path.join(maildirPath, 'dovecot-keywords'),
- path.join(folderPath, 'dovecot-keywords')
- ])]) {
- const content = await readTextFile(filePath, { optional: true });
- if (content === null) continue;
- for (const line of content.split(/\r?\n/)) {
- const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
- const index = Number(match?.[1]);
- if (!match || !Number.isInteger(index) || index < 0 || index > 25) continue;
- mapping.set(String.fromCharCode(97 + index), match[2]);
- }
- }
- return mapping;
- }
- function parseMaildirFlags(fileName, keywordMap) {
- const raw = String(fileName || '').match(/:2,([^/]*)$/)?.[1] || '';
- const flags = [];
- const keywords = [];
- for (const flag of raw) {
- if (standardMaildirFlags.has(flag)) flags.push(standardMaildirFlags.get(flag));
- else if (/[a-z]/.test(flag)) keywords.push(keywordMap.get(flag) || flag);
- else flags.push(flag);
- }
- return { raw, flags: [...new Set(flags)], keywords: [...new Set(keywords)] };
- }
- function normalizeFolder(value) {
- const clean = String(value || '').trim().replace(/^\/+|\/+$/g, '');
- const standard = standardFolders.get(clean.toLowerCase());
- return standard || clean || 'INBOX';
- }
- function normalizeAddress(value, domain = '') {
- const clean = String(value || '').trim().toLowerCase();
- if (!clean || ['no', 'none', 'reject', ':fail:'].includes(clean)) return '';
- if (['blackhole', ':blackhole:'].includes(clean)) return '/dev/null';
- return clean.includes('@') || !domain ? clean : `${clean}@${domain}`;
- }
- function normalizeAddressList(value, domain = '') {
- return [...new Set(String(value || '')
- .split(/[\s,;]+/)
- .map((item) => normalizeAddress(item, domain))
- .filter(Boolean))];
- }
- function normalizeQuota(value) {
- const clean = String(value ?? '').trim().toLowerCase();
- if (!clean || clean === '0' || clean === 'unlimited') return null;
- const quota = Number(clean);
- return Number.isFinite(quota) && quota >= 0 ? quota : null;
- }
- function passwordScheme(value) {
- const clean = String(value || '').trim();
- if (/^(?:\{(?:MD5|MD5-CRYPT)\})?\$1\$/i.test(clean)) return 'md5-crypt';
- if (/^\{[^}]+\}/.test(clean)) return clean.slice(1, clean.indexOf('}')).toLowerCase();
- return clean ? 'unknown' : '';
- }
- function vestaTimestamp(date, time) {
- const cleanDate = String(date || '').trim();
- const cleanTime = String(time || '').trim();
- return cleanDate ? `${cleanDate}${cleanTime ? `T${cleanTime}` : ''}` : null;
- }
- function isYes(value) {
- return ['yes', 'true', '1', 'on'].includes(String(value || '').trim().toLowerCase());
- }
- function domainKey(value) {
- return `${value.vestaUser || ''}\0${value.domain || value.name || ''}`;
- }
- function requireSnapshotRoot(root) {
- const clean = String(root || '').trim();
- if (!clean) throw new TypeError('Vesta 快照 root 不能为空。');
- return path.resolve(clean);
- }
- async function readTextFile(filePath, { optional = false } = {}) {
- try {
- return await readFile(filePath, 'utf8');
- } catch (error) {
- if (optional && error?.code === 'ENOENT') return null;
- throw error;
- }
- }
- async function readDirectoryEntries(directory) {
- try {
- const entries = [];
- const handle = await opendir(directory);
- for await (const entry of handle) entries.push(entry);
- return entries;
- } catch (error) {
- if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return [];
- throw error;
- }
- }
- async function isFile(filePath) {
- try {
- return (await stat(filePath)).isFile();
- } catch (error) {
- if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false;
- throw error;
- }
- }
- async function isDirectory(directory) {
- try {
- return (await stat(directory)).isDirectory();
- } catch (error) {
- if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false;
- throw error;
- }
- }
- function compareDirents(left, right) {
- return compareText(left.name, right.name);
- }
- function compareText(left, right) {
- return left < right ? -1 : left > right ? 1 : 0;
- }
- function abortIfNeeded(signal) {
- if (signal?.aborted) throw signal.reason || new Error('Vesta 导入已取消。');
- }
|