浏览代码

feat: migrate Vesta mailboxes with legacy auth

AI-Co-Authored-By: Codex
chendeben 1 月之前
父节点
当前提交
37a00905ab

+ 1 - 0
.gitignore

@@ -18,3 +18,4 @@ tmp/
 docs/superpowers/
 node_modules/
 npm-debug.log*
+report.*.json

+ 1 - 0
Dockerfile

@@ -6,6 +6,7 @@ ENV NODE_ENV=production
 COPY package.json package-lock.json ./
 RUN npm ci --omit=dev
 COPY src ./src
+COPY scripts ./scripts
 COPY public ./public
 
 RUN mkdir -p /data && chown -R node:node /data /app

+ 1 - 0
package.json

@@ -31,6 +31,7 @@
     "test": "node --test test/*.test.js",
     "test:ui": "vitest run",
     "release:check": "npm test && npm run test:ui && npm run build",
+    "import:vesta": "node scripts/import-vesta-maildir.js",
     "deploy:remote": "bash scripts/deploy-remote.sh"
   },
   "engines": {

+ 729 - 0
scripts/import-vesta-maildir.js

@@ -0,0 +1,729 @@
+#!/usr/bin/env node
+
+import crypto from 'node:crypto';
+import { existsSync, readFileSync } from 'node:fs';
+import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { DatabaseSync } from 'node:sqlite';
+
+import {
+  createDomain,
+  createInboundFolder,
+  createImportedInboundMessage,
+  getDomainByName,
+  getUser,
+  getUserByLogin,
+  hasImportedInboundMessage,
+  initDatabase,
+  updateDomain,
+  upsertImportedInboundMailbox
+} from '../src/db.js';
+import { createDkimKeyPair } from '../src/dkim.js';
+import { isLegacyPasswordHash } from '../src/password-hash.js';
+import {
+  importVestaSnapshot,
+  readVestaSnapshotMetadata
+} from '../src/vesta-maildir-import.js';
+
+const checkpointVersion = 1;
+const checkpointMessageInterval = 100;
+const checkpointTimeIntervalMs = 1000;
+
+const defaultDbApi = {
+  createDomain,
+  createInboundFolder,
+  createImportedInboundMessage,
+  getDomainByName,
+  getUser,
+  getUserByLogin,
+  hasImportedInboundMessage,
+  initDatabase,
+  updateDomain,
+  upsertImportedInboundMailbox
+};
+
+export class VestaImportCliError extends Error {
+  constructor(category, message) {
+    super(message);
+    this.name = 'VestaImportCliError';
+    this.category = category;
+  }
+}
+
+export function parseVestaImportArguments(argv, {
+  env = process.env,
+  cwd = process.cwd()
+} = {}) {
+  const values = {};
+  let help = false;
+  for (let index = 0; index < argv.length; index += 1) {
+    const argument = String(argv[index] || '');
+    if (argument === '--help' || argument === '-h') {
+      help = true;
+      continue;
+    }
+    if (argument === '--dry-run') {
+      values.dryRun = true;
+      continue;
+    }
+    const inline = argument.match(/^--([a-z-]+)=(.*)$/);
+    const name = inline?.[1] || argument.match(/^--([a-z-]+)$/)?.[1] || '';
+    if (!['snapshot', 'data-dir', 'user', 'source', 'checkpoint'].includes(name)) {
+      throw new VestaImportCliError('usage', '导入参数不正确。');
+    }
+    const value = inline ? inline[2] : argv[++index];
+    if (value === undefined || String(value).trim() === '') {
+      throw new VestaImportCliError('usage', '导入参数缺少值。');
+    }
+    values[toCamelCase(name)] = String(value).trim();
+  }
+
+  if (help) return { help: true };
+  const snapshot = values.snapshot ? path.resolve(cwd, values.snapshot) : '';
+  const dataDir = path.resolve(cwd, values.dataDir || env.DATA_DIR || 'data');
+  const user = String(values.user || '').trim();
+  const source = normalizeImportSource(values.source);
+  const checkpoint = path.resolve(
+    cwd,
+    values.checkpoint || path.join(dataDir, 'vesta-maildir-import.checkpoint.json')
+  );
+  if (!snapshot || !user || !source) {
+    throw new VestaImportCliError('usage', '必须指定快照、目标用户和导入来源。');
+  }
+  return {
+    help: false,
+    snapshot,
+    dataDir,
+    user,
+    source,
+    checkpoint,
+    dryRun: Boolean(values.dryRun)
+  };
+}
+
+export function loadMailHubEnvironment({
+  env = process.env,
+  cwd = process.cwd()
+} = {}) {
+  const file = path.join(cwd, '.env');
+  if (!existsSync(file)) return env;
+  for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
+    const clean = line.trim();
+    if (!clean || clean.startsWith('#')) continue;
+    const separator = clean.indexOf('=');
+    if (separator === -1) continue;
+    const key = clean.slice(0, separator).trim();
+    let value = clean.slice(separator + 1).trim();
+    if (!key || key in env) continue;
+    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+      value = value.slice(1, -1);
+    }
+    env[key] = value;
+  }
+  return env;
+}
+
+export async function runVestaMaildirImport(options, {
+  env = process.env,
+  output = process.stdout,
+  clock = Date.now,
+  dbApi = defaultDbApi,
+  inspectTarget = inspectMailHubTarget,
+  readSnapshot = readVestaSnapshotMetadata,
+  importSnapshot = importVestaSnapshot,
+  hashSupported = isLegacyPasswordHash,
+  signal = null
+} = {}) {
+  const normalized = normalizeRunOptions(options);
+  const inventory = inspectTarget({ dataDir: normalized.dataDir, user: normalized.user });
+  const snapshot = await readSnapshot({ root: normalized.snapshot });
+  const defaults = domainDefaults(env);
+  const checkpointIdentity = {
+    version: checkpointVersion,
+    source: normalized.source,
+    targetUserId: inventory.user.id,
+    snapshotId: snapshotIdentifier(normalized.snapshot, snapshot)
+  };
+  const previousCheckpoint = normalized.dryRun
+    ? null
+    : await readImportCheckpoint(normalized.checkpoint, checkpointIdentity);
+  const preflight = preflightVestaImport(snapshot, inventory, {
+    hashSupported,
+    defaults,
+    allowExistingMailboxes: Boolean(previousCheckpoint)
+  });
+  writeCountLine(output, 'preflight', {
+    domains: preflight.domains,
+    new_domains: preflight.newDomains,
+    mailboxes: preflight.mailboxes,
+    existing_mailboxes: preflight.existingMailboxes,
+    hashes: preflight.hashes,
+    supported_hashes: preflight.supportedHashes,
+    unsupported_hashes: preflight.unsupportedHashes,
+    missing_hashes: preflight.missingHashes,
+    conflicts: preflight.conflicts,
+    invalid_defaults: preflight.invalidDefaults,
+    warnings: preflight.warningCount
+  });
+  if (preflight.conflicts || preflight.unsupportedHashes || preflight.missingHashes || preflight.invalidDefaults) {
+    throw new VestaImportCliError('preflight', 'Vesta 导入预检失败。');
+  }
+
+  if (normalized.dryRun) {
+    const report = await importSnapshot({
+      root: normalized.snapshot,
+      dryRun: true,
+      signal
+    });
+    const summary = sanitizeImportReport(report);
+    writeCompletionLine(output, summary);
+    return { preflight, summary, checkpoint: null };
+  }
+
+  dbApi.initDatabase(normalized.dataDir, mailHubSecret(env));
+  const targetUser = resolveDbUser(dbApi, normalized.user);
+  if (!targetUser || targetUser.id !== inventory.user.id || targetUser.status === 'disabled') {
+    throw new VestaImportCliError('target', '目标用户不可用。');
+  }
+
+  // Always rescan the snapshot and let the database source key deduplicate it.
+  // A file checkpoint cannot prove that earlier rows survived a DB rollback.
+  const resumeCheckpoint = previousCheckpoint
+    ? {
+        ...previousCheckpoint,
+        lastSourceKey: '',
+        processed: 0,
+        bytes: 0,
+        complete: false
+      }
+    : null;
+  const progress = {
+    imported: 0,
+    skipped: 0,
+    processed: Number(resumeCheckpoint?.processed || 0),
+    bytes: Number(resumeCheckpoint?.bytes || 0)
+  };
+  const checkpointWriter = createAtomicCheckpointWriter({
+    filePath: normalized.checkpoint,
+    identity: checkpointIdentity,
+    initial: {
+      ...(resumeCheckpoint || {}),
+      warningCount: preflight.warningCount
+    },
+    clock
+  });
+  await checkpointWriter.flush({ force: true, complete: false });
+  const adapter = createMailHubImportAdapter({
+    dbApi,
+    user: targetUser,
+    source: normalized.source,
+    defaults,
+    progress
+  });
+
+  let report;
+  try {
+    report = await importSnapshot({
+      root: normalized.snapshot,
+      adapter,
+      resumeAfterSourceKey: '',
+      signal,
+      onCheckpoint: async (sourceKey, context) => {
+        progress.processed += 1;
+        progress.bytes += Number(context?.message?.size || context?.message?.rawMessageBytes?.length || 0);
+        const flushed = await checkpointWriter.record(sourceKey, progress);
+        if (flushed) {
+          writeCountLine(output, 'progress', {
+            processed: progress.processed,
+            imported: progress.imported,
+            skipped: progress.skipped,
+            bytes: progress.bytes
+          });
+        }
+      }
+    });
+  } catch (error) {
+    await checkpointWriter.flush({ force: true, complete: false });
+    throw error;
+  }
+
+  const summary = sanitizeImportReport(report);
+  await checkpointWriter.flush({
+    force: true,
+    complete: true,
+    report: summary,
+    fallbackSourceKey: report.lastSourceKey || resumeCheckpoint?.lastSourceKey || ''
+  });
+  writeCompletionLine(output, summary);
+  return {
+    preflight,
+    summary,
+    checkpoint: checkpointWriter.snapshot()
+  };
+}
+
+export function inspectMailHubTarget({ dataDir, user }) {
+  const databaseFile = path.join(path.resolve(dataDir), 'mailhub.sqlite');
+  if (!existsSync(databaseFile)) throw new VestaImportCliError('target', '目标数据库不存在。');
+  let database;
+  try {
+    database = new DatabaseSync(databaseFile, { readOnly: true });
+    const target = parseTargetUser(user);
+    const userRow = target.id
+      ? database.prepare('SELECT id, username, email, status FROM users WHERE id = ?').get(target.id)
+      : database.prepare('SELECT id, username, email, status FROM users WHERE lower(username) = ? OR lower(email) = ?').get(target.login, target.login);
+    if (!userRow || userRow.status === 'disabled') throw new VestaImportCliError('target', '目标用户不可用。');
+    const domains = database
+      .prepare('SELECT id, user_id, lower(domain) AS domain FROM domains')
+      .all()
+      .map((row) => ({ id: Number(row.id), userId: Number(row.user_id), domain: row.domain }));
+    const mailboxes = database
+      .prepare(`
+        SELECT
+          m.id,
+          m.user_id,
+          lower(m.address) AS address,
+          lower(d.domain) AS domain,
+          m.deleted_at
+        FROM inbound_mailboxes m
+        JOIN domains d ON d.id = m.domain_id
+      `)
+      .all()
+      .map((row) => ({
+        id: Number(row.id),
+        userId: Number(row.user_id),
+        address: row.address,
+        domain: row.domain,
+        deletedAt: row.deleted_at || null
+      }));
+    return {
+      user: {
+        id: Number(userRow.id),
+        username: userRow.username,
+        email: userRow.email,
+        status: userRow.status
+      },
+      domains,
+      mailboxes
+    };
+  } catch (error) {
+    if (error instanceof VestaImportCliError) throw error;
+    throw new VestaImportCliError('target', '目标数据库无法读取。');
+  } finally {
+    database?.close();
+  }
+}
+
+export function preflightVestaImport(snapshot, inventory, {
+  hashSupported = isLegacyPasswordHash,
+  defaults = {},
+  allowExistingMailboxes = false
+} = {}) {
+  const targetUserId = Number(inventory.user.id);
+  const existingDomains = new Map(inventory.domains.map((domain) => [domain.domain, domain]));
+  const existingMailboxes = new Map(inventory.mailboxes.map((mailbox) => [mailbox.address, mailbox]));
+  const sourceDomains = new Set();
+  const sourceMailboxes = new Set();
+  let conflicts = 0;
+  let newDomains = 0;
+  let hashes = 0;
+  let supportedHashes = 0;
+  let unsupportedHashes = 0;
+  let missingHashes = 0;
+  let existingMailboxCount = 0;
+
+  for (const domain of snapshot.domains) {
+    const name = normalizeDomainName(domain.domain || domain.name);
+    if (!name || sourceDomains.has(name)) conflicts += 1;
+    else sourceDomains.add(name);
+    const existing = existingDomains.get(name);
+    if (existing && existing.userId !== targetUserId) conflicts += 1;
+    if (!existing) newDomains += 1;
+  }
+  for (const mailbox of snapshot.mailboxes) {
+    const address = normalizeMailboxAddress(mailbox.address);
+    if (!address) {
+      conflicts += 1;
+    } else {
+      if (sourceMailboxes.has(address)) conflicts += 1;
+      else sourceMailboxes.add(address);
+      const mailboxDomain = address.slice(address.lastIndexOf('@') + 1);
+      if (!sourceDomains.has(mailboxDomain)) conflicts += 1;
+      const existing = existingMailboxes.get(address);
+      if (existing) {
+        existingMailboxCount += 1;
+        if (
+          existing.userId !== targetUserId
+          || existing.deletedAt
+          || !allowExistingMailboxes
+        ) conflicts += 1;
+      }
+    }
+    const passwordHash = String(mailbox.passwordHash || mailbox.legacyPasswordHash || '').trim();
+    if (!passwordHash) {
+      missingHashes += 1;
+      continue;
+    }
+    hashes += 1;
+    if (hashSupported(passwordHash)) supportedHashes += 1;
+    else unsupportedHashes += 1;
+  }
+  return {
+    domains: snapshot.domains.length,
+    newDomains,
+    mailboxes: snapshot.mailboxes.length,
+    existingMailboxes: existingMailboxCount,
+    hashes,
+    supportedHashes,
+    unsupportedHashes,
+    missingHashes,
+    conflicts,
+    invalidDefaults: newDomains > 0 && !validNewDomainDefaults(defaults) ? 1 : 0,
+    warningCount: Array.isArray(snapshot.warnings) ? snapshot.warnings.length : 0
+  };
+}
+
+export function createMailHubImportAdapter({ dbApi, user, source, defaults, progress }) {
+  return {
+    async ensureDomain(sourceDomain) {
+      const domainName = normalizeDomainName(sourceDomain.domain || sourceDomain.name);
+      const catchAllAddress = String(sourceDomain.catchAllAddress ?? sourceDomain.catchAll ?? '').trim();
+      const existing = dbApi.getDomainByName(domainName);
+      if (existing) {
+        if (Number(existing.userId) !== Number(user.id)) {
+          throw new VestaImportCliError('conflict', '现有域名归属冲突。');
+        }
+        return dbApi.updateDomain(existing.id, user.id, { catchAllAddress });
+      }
+      if (!validNewDomainDefaults(defaults)) {
+        throw new VestaImportCliError('configuration', '新域名默认配置不可用。');
+      }
+      const keys = createDkimKeyPair();
+      const created = dbApi.createDomain(user.id, {
+        domain: domainName,
+        selector: defaultSelector(),
+        verificationToken: crypto.randomBytes(18).toString('hex'),
+        dkimPublic: keys.publicKey,
+        dkimPrivate: keys.privateKey,
+        senderHost: defaults.senderHost,
+        sendingIp: defaults.sendingIp,
+        spfExtra: defaults.spfExtra,
+        dmarcPolicy: defaults.dmarcPolicy,
+        dmarcRua: defaults.dmarcRua
+      });
+      return dbApi.updateDomain(created.id, user.id, { catchAllAddress });
+    },
+
+    async ensureMailbox(mailbox) {
+      return dbApi.upsertImportedInboundMailbox(user.id, {
+        address: mailbox.address,
+        displayName: mailbox.displayName,
+        passwordHash: mailbox.passwordHash || mailbox.legacyPasswordHash || '',
+        aliases: mailbox.aliases,
+        forwardTo: mailbox.forwardTo,
+        keepForwarded: mailbox.keepForwarded ?? !mailbox.forwardOnly,
+        quotaMb: mailbox.quotaMb,
+        status: mailbox.suspended || mailbox.status === 'suspended' ? 'disabled' : 'active'
+      });
+    },
+
+    async ensureFolder(mailbox, folder) {
+      return dbApi.createInboundFolder(mailbox, folder);
+    },
+
+    async hasMessage(sourceKey) {
+      const exists = dbApi.hasImportedInboundMessage(source, sourceKey);
+      if (exists) progress.skipped += 1;
+      return exists;
+    },
+
+    async createMessage(message, context) {
+      const result = dbApi.createImportedInboundMessage(context.mailbox, {
+        ...message,
+        importSource: source,
+        sourceKey: message.sourceKey
+      });
+      if (result.created) progress.imported += 1;
+      else progress.skipped += 1;
+      return result;
+    }
+  };
+}
+
+export function createAtomicCheckpointWriter({
+  filePath,
+  identity,
+  initial = null,
+  clock = Date.now,
+  messageInterval = checkpointMessageInterval,
+  timeIntervalMs = checkpointTimeIntervalMs
+}) {
+  let pending = 0;
+  let lastFlushAt = clock();
+  let state = {
+    ...identity,
+    lastSourceKey: String(initial?.lastSourceKey || ''),
+    processed: Number(initial?.processed || 0),
+    bytes: Number(initial?.bytes || 0),
+    complete: Boolean(initial?.complete),
+    warningCount: Number(initial?.warningCount || 0)
+  };
+
+  async function flush({ force = false, complete = state.complete, report = null, fallbackSourceKey = '' } = {}) {
+    if (!force && pending < messageInterval && clock() - lastFlushAt < timeIntervalMs) return false;
+    if (!pending && !force) return false;
+    state = {
+      ...state,
+      lastSourceKey: state.lastSourceKey || String(fallbackSourceKey || ''),
+      complete: Boolean(complete),
+      warningCount: Number(report?.warningCount ?? state.warningCount ?? 0),
+      updatedAt: new Date(clock()).toISOString()
+    };
+    await atomicWriteJson(filePath, state);
+    pending = 0;
+    lastFlushAt = clock();
+    return true;
+  }
+
+  return {
+    async record(sourceKey, progress) {
+      state = {
+        ...state,
+        lastSourceKey: String(sourceKey || state.lastSourceKey || ''),
+        processed: Number(progress.processed || 0),
+        bytes: Number(progress.bytes || 0),
+        complete: false
+      };
+      pending += 1;
+      if (pending < messageInterval && clock() - lastFlushAt < timeIntervalMs) return false;
+      return await flush();
+    },
+    flush,
+    snapshot() {
+      return { ...state };
+    }
+  };
+}
+
+export async function readImportCheckpoint(filePath, identity) {
+  let checkpoint;
+  try {
+    checkpoint = JSON.parse(await readFile(filePath, 'utf8'));
+  } catch (error) {
+    if (error?.code === 'ENOENT') return null;
+    throw new VestaImportCliError('checkpoint', '导入断点无法读取。');
+  }
+  if (
+    checkpoint?.version !== identity.version
+    || checkpoint?.source !== identity.source
+    || Number(checkpoint?.targetUserId) !== Number(identity.targetUserId)
+    || checkpoint?.snapshotId !== identity.snapshotId
+    || typeof checkpoint?.lastSourceKey !== 'string'
+  ) {
+    throw new VestaImportCliError('checkpoint', '导入断点与本次任务不匹配。');
+  }
+  return checkpoint;
+}
+
+export function sanitizeImportReport(report) {
+  return {
+    dryRun: Boolean(report?.dryRun),
+    domains: Number(report?.domains || 0),
+    mailboxes: Number(report?.mailboxes || 0),
+    messages: Number(report?.messages || 0),
+    bytes: Number(report?.bytes || 0),
+    plannedMessages: Number(report?.plannedMessages || 0),
+    importedMessages: Number(report?.importedMessages || 0),
+    skippedMessages: Number(report?.skippedMessages || 0),
+    resumeSkippedMessages: Number(report?.resumeSkippedMessages || 0),
+    warningCount: Number(report?.warningCount ?? (Array.isArray(report?.warnings) ? report.warnings.length : 0))
+  };
+}
+
+export function usageText() {
+  return [
+    'Usage: node scripts/import-vesta-maildir.js --snapshot PATH --data-dir PATH --user USER --source NAME [--checkpoint FILE] [--dry-run]',
+    '',
+    'USER accepts a username/email, or an explicit numeric id in the form id:123.',
+    'Run --dry-run first. The checkpoint is written only during a real import.'
+  ].join('\n');
+}
+
+async function main() {
+  let options;
+  try {
+    loadMailHubEnvironment();
+    options = parseVestaImportArguments(process.argv.slice(2));
+    if (options.help) {
+      process.stdout.write(`${usageText()}\n`);
+      return;
+    }
+    const controller = new AbortController();
+    const abort = () => controller.abort(new Error('Vesta 导入已取消。'));
+    process.once('SIGINT', abort);
+    process.once('SIGTERM', abort);
+    try {
+      await runVestaMaildirImport(options, { signal: controller.signal });
+    } finally {
+      process.off('SIGINT', abort);
+      process.off('SIGTERM', abort);
+    }
+  } catch (error) {
+    const category = safeCategory(error?.category);
+    process.stderr.write(`failed errors=1 category=${category}\n`);
+    process.exitCode = 1;
+  }
+}
+
+function normalizeRunOptions(options = {}) {
+  const source = normalizeImportSource(options.source);
+  const snapshot = path.resolve(String(options.snapshot || ''));
+  const dataDir = path.resolve(String(options.dataDir || ''));
+  const checkpoint = path.resolve(String(options.checkpoint || path.join(dataDir, 'vesta-maildir-import.checkpoint.json')));
+  const user = String(options.user || '').trim();
+  if (!source || !user || !options.snapshot || !options.dataDir) {
+    throw new VestaImportCliError('usage', '导入参数不完整。');
+  }
+  return { source, snapshot, dataDir, checkpoint, user, dryRun: Boolean(options.dryRun) };
+}
+
+function resolveDbUser(dbApi, identifier) {
+  const target = parseTargetUser(identifier);
+  return target.id ? dbApi.getUser(target.id) : dbApi.getUserByLogin(target.login);
+}
+
+function parseTargetUser(value) {
+  const clean = String(value || '').trim().toLowerCase();
+  const explicitId = clean.match(/^id:(\d+)$/);
+  return explicitId
+    ? { id: Number(explicitId[1]), login: '' }
+    : { id: 0, login: clean };
+}
+
+function domainDefaults(env) {
+  const policy = String(env.DMARC_POLICY || 'none').trim().toLowerCase();
+  return {
+    senderHost: String(env.MAIL_HOSTNAME || 'mailhub.local').trim().toLowerCase(),
+    sendingIp: String(env.SENDING_IP || '').trim(),
+    spfExtra: String(env.DEFAULT_SPF_MECHANISMS || 'include:spf.mailjet.com').trim(),
+    dmarcPolicy: ['none', 'quarantine', 'reject'].includes(policy) ? policy : 'none',
+    dmarcRua: String(env.DMARC_RUA || '').trim()
+  };
+}
+
+function validNewDomainDefaults(defaults) {
+  return validHostname(defaults.senderHost) && Boolean(String(defaults.sendingIp || '').trim());
+}
+
+function validHostname(value) {
+  const hostname = String(value || '').trim().toLowerCase().replace(/\.$/, '');
+  if (!hostname || hostname.length > 253) return false;
+  return hostname.split('.').every((label) => (
+    label.length > 0
+    && label.length <= 63
+    && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
+  ));
+}
+
+function mailHubSecret(env) {
+  if (env.SESSION_SECRET) return String(env.SESSION_SECRET);
+  return crypto
+    .createHash('sha256')
+    .update(`${env.ADMIN_PASSWORD || 'change-this-admin-password'}:${env.API_TOKEN || ''}`)
+    .digest('hex');
+}
+
+function defaultSelector() {
+  const date = new Date();
+  return `mh${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
+}
+
+function snapshotIdentifier(snapshotPath, snapshot = {}) {
+  const fingerprint = {
+    path: path.resolve(snapshotPath),
+    domains: Array.isArray(snapshot.domains) ? snapshot.domains : [],
+    mailboxes: Array.isArray(snapshot.mailboxes) ? snapshot.mailboxes : [],
+    warnings: Array.isArray(snapshot.warnings) ? snapshot.warnings : []
+  };
+  return crypto
+    .createHash('sha256')
+    .update(JSON.stringify(sortJsonValue(fingerprint)))
+    .digest('hex');
+}
+
+function sortJsonValue(value) {
+  if (Array.isArray(value)) return value.map(sortJsonValue);
+  if (!value || typeof value !== 'object') return value;
+  return Object.fromEntries(
+    Object.keys(value)
+      .sort()
+      .map((key) => [key, sortJsonValue(value[key])])
+  );
+}
+
+function normalizeImportSource(value) {
+  const source = String(value || '').trim().toLowerCase();
+  if (!source || source.length > 120 || !/^[a-z0-9][a-z0-9._:@/-]*$/.test(source)) return '';
+  return source;
+}
+
+function normalizeDomainName(value) {
+  const domain = String(value || '').trim().toLowerCase();
+  return /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(domain)
+    ? domain
+    : '';
+}
+
+function normalizeMailboxAddress(value) {
+  const address = String(value || '').trim().toLowerCase();
+  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address) ? address : '';
+}
+
+function toCamelCase(value) {
+  return String(value).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
+}
+
+function writeCompletionLine(output, summary) {
+  writeCountLine(output, 'complete', {
+    dry_run: summary.dryRun ? 1 : 0,
+    domains: summary.domains,
+    mailboxes: summary.mailboxes,
+    messages: summary.messages,
+    bytes: summary.bytes,
+    planned: summary.plannedMessages,
+    imported: summary.importedMessages,
+    skipped: summary.skippedMessages,
+    resume_skipped: summary.resumeSkippedMessages,
+    warnings: summary.warningCount
+  });
+}
+
+function writeCountLine(output, event, values) {
+  const fields = Object.entries(values).map(([key, value]) => `${key}=${Number(value || 0)}`);
+  output.write(`${event} ${fields.join(' ')}\n`);
+}
+
+function safeCategory(value) {
+  const clean = String(value || 'import').trim().toLowerCase();
+  return /^[a-z][a-z0-9_-]{0,31}$/.test(clean) ? clean : 'import';
+}
+
+async function atomicWriteJson(filePath, value) {
+  const directory = path.dirname(filePath);
+  await mkdir(directory, { recursive: true });
+  const temporary = path.join(
+    directory,
+    `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`
+  );
+  try {
+    await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
+    await rename(temporary, filePath);
+  } catch (error) {
+    await rm(temporary, { force: true });
+    throw error;
+  }
+}
+
+const invokedUrl = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '';
+if (import.meta.url === invokedUrl) await main();

+ 172 - 0
src/auth-rate-limit.js

@@ -0,0 +1,172 @@
+const tenMinutesMs = 10 * 60 * 1000;
+
+export const defaultAuthenticationRateLimits = Object.freeze({
+  windowMs: tenMinutesMs,
+  blockMs: tenMinutesMs,
+  combinationLimit: 10,
+  accountLimit: 30,
+  ipLimit: 100,
+  maxEntries: 50_000,
+  cleanupIntervalMs: 60 * 1000
+});
+
+export class AuthenticationRateLimiter {
+  constructor(options = {}) {
+    this.windowMs = positiveInteger(options.windowMs, defaultAuthenticationRateLimits.windowMs);
+    this.blockMs = positiveInteger(options.blockMs, defaultAuthenticationRateLimits.blockMs);
+    this.combinationLimit = positiveInteger(
+      options.combinationLimit,
+      defaultAuthenticationRateLimits.combinationLimit
+    );
+    this.accountLimit = positiveInteger(options.accountLimit, defaultAuthenticationRateLimits.accountLimit);
+    this.ipLimit = positiveInteger(options.ipLimit, defaultAuthenticationRateLimits.ipLimit);
+    this.maxEntries = positiveInteger(options.maxEntries, defaultAuthenticationRateLimits.maxEntries);
+    this.cleanupIntervalMs = positiveInteger(
+      options.cleanupIntervalMs,
+      defaultAuthenticationRateLimits.cleanupIntervalMs
+    );
+    this.now = typeof options.now === 'function' ? options.now : Date.now;
+    this.entries = new Map();
+    this.lastCleanupAt = 0;
+  }
+
+  isBlocked({ ip, account }) {
+    const now = this.now();
+    this.cleanupIfNeeded(now);
+    return authenticationKeys(ip, account).some(({ key }) => {
+      const entry = this.readEntry(key, now);
+      return Boolean(entry && entry.blockedUntil > now);
+    });
+  }
+
+  recordFailure({ ip, account }) {
+    const now = this.now();
+    this.cleanupIfNeeded(now);
+    let blocked = false;
+    for (const { key, limit } of authenticationKeys(ip, account, this)) {
+      const entry = this.readEntry(key, now) || { failures: [], blockedUntil: 0 };
+      entry.failures.push(now);
+      if (entry.failures.length >= limit) {
+        entry.blockedUntil = Math.max(entry.blockedUntil, now + this.blockMs);
+        blocked = true;
+      }
+      this.touchEntry(key, entry);
+    }
+    this.enforceEntryLimit();
+    return blocked;
+  }
+
+  recordSuccess({ ip, account }) {
+    const normalizedIp = normalizeAuthenticationIp(ip);
+    const normalizedAccount = normalizeAuthenticationAccount(account);
+    this.entries.delete(combinationKey(normalizedIp, normalizedAccount));
+    this.entries.delete(accountKey(normalizedAccount));
+  }
+
+  reset() {
+    this.entries.clear();
+    this.lastCleanupAt = 0;
+  }
+
+  get entryCount() {
+    return this.entries.size;
+  }
+
+  readEntry(key, now) {
+    const entry = this.entries.get(key);
+    if (!entry) return null;
+    const cutoff = now - this.windowMs;
+    entry.failures = entry.failures.filter((timestamp) => timestamp > cutoff);
+    if (entry.blockedUntil <= now) entry.blockedUntil = 0;
+    if (!entry.failures.length && !entry.blockedUntil) {
+      this.entries.delete(key);
+      return null;
+    }
+    this.touchEntry(key, entry);
+    return entry;
+  }
+
+  touchEntry(key, entry) {
+    this.entries.delete(key);
+    this.entries.set(key, entry);
+  }
+
+  cleanupIfNeeded(now) {
+    if (
+      this.entries.size <= this.maxEntries
+      && now - this.lastCleanupAt < this.cleanupIntervalMs
+    ) return;
+    for (const key of [...this.entries.keys()]) this.readEntry(key, now);
+    this.enforceEntryLimit();
+    this.lastCleanupAt = now;
+  }
+
+  enforceEntryLimit() {
+    while (this.entries.size > this.maxEntries) {
+      const oldestKey = this.entries.keys().next().value;
+      if (oldestKey === undefined) return;
+      this.entries.delete(oldestKey);
+    }
+  }
+}
+
+export const authenticationRateLimiter = new AuthenticationRateLimiter();
+
+export function authenticateWithRateLimit({ limiter = authenticationRateLimiter, ip, account, authenticate }) {
+  const identity = { ip, account };
+  if (limiter.isBlocked(identity)) return null;
+  const result = authenticate();
+  if (result) {
+    limiter.recordSuccess(identity);
+    return result;
+  }
+  limiter.recordFailure(identity);
+  return null;
+}
+
+export function normalizeAuthenticationIp(value) {
+  const raw = String(value || '').trim().toLowerCase();
+  if (!raw) return 'unknown';
+  const unwrapped = raw.startsWith('[') && raw.endsWith(']') ? raw.slice(1, -1) : raw;
+  return (unwrapped.startsWith('::ffff:') ? unwrapped.slice(7) : unwrapped).slice(0, 128);
+}
+
+export function normalizeAuthenticationAccount(value) {
+  return String(value || '').trim().toLowerCase().slice(0, 320) || 'unknown';
+}
+
+function authenticationKeys(ip, account, limiter) {
+  const normalizedIp = normalizeAuthenticationIp(ip);
+  const normalizedAccount = normalizeAuthenticationAccount(account);
+  return [
+    {
+      key: combinationKey(normalizedIp, normalizedAccount),
+      limit: limiter?.combinationLimit
+    },
+    {
+      key: accountKey(normalizedAccount),
+      limit: limiter?.accountLimit
+    },
+    {
+      key: ipKey(normalizedIp),
+      limit: limiter?.ipLimit
+    }
+  ];
+}
+
+function combinationKey(ip, account) {
+  return `combination:${JSON.stringify([ip, account])}`;
+}
+
+function accountKey(account) {
+  return `account:${account}`;
+}
+
+function ipKey(ip) {
+  return `ip:${ip}`;
+}
+
+function positiveInteger(value, fallback) {
+  const parsed = Number(value);
+  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
+}

+ 330 - 26
src/db.js

@@ -1,8 +1,9 @@
-import { mkdirSync } from 'node:fs';
+import { chmodSync, existsSync, mkdirSync } from 'node:fs';
 import crypto from 'node:crypto';
 import path from 'node:path';
 import { DatabaseSync } from 'node:sqlite';
 import { dkimPublicFromPrivateKey } from './dkim.js';
+import { hashPassword, isLegacyPasswordHash, verifyPassword } from './password-hash.js';
 import { decryptTrackingTarget, hashTrackingToken } from './tracking.js';
 import {
   MAX_WEBHOOK_ATTEMPTS,
@@ -31,8 +32,11 @@ const defaultApiTokenScopes = ['send'];
 
 export function initDatabase(dataDir, secret = '') {
   secretKey = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
-  mkdirSync(dataDir, { recursive: true });
-  db = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'));
+  const databasePath = path.join(dataDir, 'mailhub.sqlite');
+  mkdirSync(dataDir, { recursive: true, mode: 0o700 });
+  chmodSync(dataDir, 0o700);
+  db = new DatabaseSync(databasePath);
+  chmodSync(databasePath, 0o600);
   db.function('normalize_failure_reason', { deterministic: true }, (detail, status) => (
     String(detail || '').replace(/\s+/g, ' ').trim() || String(status || 'unknown failure')
   ));
@@ -192,6 +196,11 @@ export function initDatabase(dataDir, secret = '') {
       html_body TEXT NOT NULL DEFAULT '',
       preview TEXT NOT NULL DEFAULT '',
       read_state TEXT NOT NULL DEFAULT 'false',
+      flags_json TEXT NOT NULL DEFAULT '[]',
+      keywords_json TEXT NOT NULL DEFAULT '[]',
+      import_source TEXT NOT NULL DEFAULT '',
+      import_source_key TEXT NOT NULL DEFAULT '',
+      pop3_size INTEGER NOT NULL DEFAULT 0,
       received_at TEXT NOT NULL,
       created_at TEXT NOT NULL,
       updated_at TEXT NOT NULL,
@@ -349,6 +358,12 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('inbound_mailboxes', 'expires_at', 'TEXT');
   ensureColumn('inbound_messages', 'folder', "TEXT NOT NULL DEFAULT 'INBOX'");
   ensureColumn('inbound_messages', 'raw_message_bytes', 'BLOB');
+  ensureColumn('inbound_messages', 'flags_json', "TEXT NOT NULL DEFAULT '[]'");
+  ensureColumn('inbound_messages', 'keywords_json', "TEXT NOT NULL DEFAULT '[]'");
+  ensureColumn('inbound_messages', 'import_source', "TEXT NOT NULL DEFAULT ''");
+  ensureColumn('inbound_messages', 'import_source_key', "TEXT NOT NULL DEFAULT ''");
+  ensureColumn('inbound_messages', 'pop3_size', 'INTEGER NOT NULL DEFAULT 0');
+  backfillInboundPop3Sizes();
   ensureColumn('webhooks', 'mailbox_id', 'INTEGER');
   migrateWebhookDeliveriesForInbound();
   ensureColumn('api_tokens', 'scopes_json', "TEXT NOT NULL DEFAULT '[\"send\"]'");
@@ -371,6 +386,9 @@ export function initDatabase(dataDir, secret = '') {
     CREATE INDEX IF NOT EXISTS idx_smtp_relays_user_id ON smtp_relays(user_id);
     CREATE INDEX IF NOT EXISTS idx_api_tokens_user_status ON api_tokens(user_id, revoked_at, expires_at);
     CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_folder_received ON inbound_messages(mailbox_id, folder, received_at);
+    CREATE UNIQUE INDEX IF NOT EXISTS idx_inbound_messages_import_source_key
+      ON inbound_messages(import_source, import_source_key)
+      WHERE import_source != '' AND import_source_key != '';
     CREATE INDEX IF NOT EXISTS idx_inbound_folders_mailbox ON inbound_folders(mailbox_id, deleted_at);
     CREATE INDEX IF NOT EXISTS idx_webhooks_user_mailbox ON webhooks(user_id, mailbox_id);
     CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_status_next ON webhook_deliveries(status, next_attempt_at);
@@ -384,6 +402,7 @@ export function initDatabase(dataDir, secret = '') {
   `);
   normalizeSendEventQueueIds();
   normalizeDkimPublicKeys();
+  secureDatabaseStorage(dataDir, databasePath);
   return db;
 }
 
@@ -950,6 +969,80 @@ export function createInboundMailbox(userId, mailbox = {}) {
   return getInboundMailbox(result.lastInsertRowid, userId);
 }
 
+export function upsertImportedInboundMailbox(userId, mailbox = {}) {
+  const address = normalizeInboundAddress(mailbox.address);
+  if (!address) throw new Error('导入邮箱格式不正确。');
+  const [localPart, domainName] = address.split('@');
+  const domain = getDomainByName(domainName, { userId });
+  if (!domain) throw new Error('导入邮箱所属域名不存在。');
+  const passwordHash = String(mailbox.passwordHash || '').trim();
+  if (passwordHash && !isLegacyPasswordHash(passwordHash)) {
+    throw new Error('导入邮箱使用了不支持的旧密码格式。');
+  }
+  const aliases = normalizeMailboxAliases(mailbox.aliases, domain.domain, localPart);
+  const forwardTo = normalizeRecipientList(mailbox.forwardTo);
+  const keepForwarded = boolString(mailbox.keepForwarded ?? true);
+  const quotaMb = normalizeQuotaMb(mailbox.quotaMb);
+  const status = normalizeInboundMailboxStatus(mailbox.status || 'active');
+  const updatedAt = now();
+  const existing = requireDb()
+    .prepare('SELECT * FROM inbound_mailboxes WHERE address = ? LIMIT 1')
+    .get(address);
+
+  if (existing) {
+    if (existing.user_id !== Number(userId) || existing.domain_id !== Number(domain.id) || existing.deleted_at) {
+      throw new Error('导入邮箱与现有资源冲突。');
+    }
+    const nextPasswordHash = !existing.password_hash || isLegacyPasswordHash(existing.password_hash)
+      ? (passwordHash || existing.password_hash)
+      : existing.password_hash;
+    requireDb()
+      .prepare(`
+        UPDATE inbound_mailboxes
+        SET display_name = ?, password_hash = ?, aliases_json = ?, forward_to_json = ?,
+            keep_forwarded = ?, quota_mb = ?, status = ?, updated_at = ?
+        WHERE id = ? AND user_id = ? AND deleted_at IS NULL
+      `)
+      .run(
+        String(mailbox.displayName || '').trim(),
+        nextPasswordHash || '',
+        JSON.stringify(aliases),
+        JSON.stringify(forwardTo),
+        keepForwarded,
+        quotaMb,
+        status,
+        updatedAt,
+        existing.id,
+        userId
+      );
+    return getInboundMailbox(existing.id, userId);
+  }
+
+  const result = requireDb()
+    .prepare(`
+      INSERT INTO inbound_mailboxes (
+        user_id, domain_id, address, local_part, display_name, password_hash, password_secret,
+        aliases_json, forward_to_json, keep_forwarded, quota_mb, status, created_at, updated_at
+      ) VALUES (?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?)
+    `)
+    .run(
+      userId,
+      domain.id,
+      address,
+      localPart,
+      String(mailbox.displayName || '').trim(),
+      passwordHash,
+      JSON.stringify(aliases),
+      JSON.stringify(forwardTo),
+      keepForwarded,
+      quotaMb,
+      status,
+      updatedAt,
+      updatedAt
+    );
+  return getInboundMailbox(result.lastInsertRowid, userId);
+}
+
 export function updateInboundMailbox(userId, id, patch = {}) {
   const current = getInboundMailbox(id, userId, { includeSecret: true });
   if (!current) return null;
@@ -1086,6 +1179,20 @@ export function verifyInboundMailboxCredential(username, password) {
     `)
     .get(mailboxAddress, now());
   if (!row?.password_hash || row.user_status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
+  if (isLegacyPasswordHash(row.password_hash)) {
+    const upgradedAt = now();
+    requireDb()
+      .prepare(`
+        UPDATE inbound_mailboxes
+        SET password_hash = ?, updated_at = ?
+        WHERE id = ? AND password_hash = ?
+      `)
+      .run(hashPassword(password), upgradedAt, row.id, row.password_hash);
+    row.password_hash = requireDb()
+      .prepare('SELECT password_hash FROM inbound_mailboxes WHERE id = ?')
+      .get(row.id)?.password_hash || row.password_hash;
+    row.updated_at = upgradedAt;
+  }
   return {
     user: {
       id: row.auth_user_id,
@@ -1158,8 +1265,9 @@ export function createInboundMessage(mailbox, message = {}) {
     .prepare(`
       INSERT INTO inbound_messages (
         mailbox_id, user_id, domain_id, folder, sender, recipients_json, subject, message_id,
-        raw_message, raw_message_bytes, text_body, html_body, preview, read_state, received_at, created_at, updated_at
-      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'false', ?, ?, ?)
+        raw_message, raw_message_bytes, text_body, html_body, preview, read_state, pop3_size,
+        received_at, created_at, updated_at
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'false', ?, ?, ?, ?)
     `)
     .run(
       mailbox.id,
@@ -1175,6 +1283,7 @@ export function createInboundMessage(mailbox, message = {}) {
       textBody,
       htmlBody,
       inboundPreview(textBody || htmlToText(htmlBody)),
+      canonicalPop3MessageSize(rawMessageBytes),
       receivedAt,
       receivedAt,
       receivedAt
@@ -1182,6 +1291,84 @@ export function createInboundMessage(mailbox, message = {}) {
   return getInboundMessage(mailbox.userId, result.lastInsertRowid);
 }
 
+export function hasImportedInboundMessage(importSource, sourceKey) {
+  const source = normalizeImportSource(importSource);
+  const key = normalizeImportSourceKey(sourceKey);
+  if (!source || !key) return false;
+  return Boolean(requireDb()
+    .prepare(`
+      SELECT 1
+      FROM inbound_messages
+      WHERE import_source = ? AND import_source_key = ?
+      LIMIT 1
+    `)
+    .get(source, key));
+}
+
+export function createImportedInboundMessage(mailbox, message = {}) {
+  if (!mailbox?.id || !mailbox?.userId || !mailbox?.domainId) throw new Error('导入邮箱不存在。');
+  const importSource = normalizeImportSource(message.importSource);
+  const sourceKey = normalizeImportSourceKey(message.sourceKey);
+  if (!importSource || !sourceKey) throw new Error('导入邮件缺少稳定来源标识。');
+  const existing = requireDb()
+    .prepare('SELECT id FROM inbound_messages WHERE import_source = ? AND import_source_key = ? LIMIT 1')
+    .get(importSource, sourceKey);
+  if (existing) return { created: false, message: { id: Number(existing.id) } };
+
+  const receivedAt = normalizeImportedReceivedAt(message.receivedAt);
+  const folder = normalizeInboundFolder(message.folder) || 'INBOX';
+  const textBody = String(message.textBody || '');
+  const htmlBody = String(message.htmlBody || '');
+  const rawMessageBytes = Buffer.from(message.rawMessageBytes || Buffer.alloc(0));
+  const flags = normalizeImportedStringList(message.flags);
+  const keywords = normalizeImportedStringList(message.keywords);
+  const read = message.read === undefined
+    ? flags.some((flag) => flag.toLowerCase() === '\\seen')
+    : Boolean(message.read);
+  if (!isStandardInboundFolder(folder)) createInboundFolder(mailbox, folder);
+  const insertedAt = now();
+  const result = requireDb()
+    .prepare(`
+      INSERT OR IGNORE INTO inbound_messages (
+        mailbox_id, user_id, domain_id, folder, sender, recipients_json, subject, message_id,
+        raw_message, raw_message_bytes, text_body, html_body, preview, read_state,
+        flags_json, keywords_json, import_source, import_source_key,
+        pop3_size, received_at, created_at, updated_at
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+    `)
+    .run(
+      mailbox.id,
+      mailbox.userId,
+      mailbox.domainId,
+      folder,
+      normalizeEmail(message.sender) || String(message.sender || '').trim(),
+      JSON.stringify(normalizeRecipientList(message.recipients)),
+      String(message.subject || '').trim() || '(no subject)',
+      String(message.messageId || '').trim(),
+      rawMessageBytes,
+      textBody,
+      htmlBody,
+      String(message.preview || '').trim() || inboundPreview(textBody || htmlToText(htmlBody)),
+      boolString(read),
+      JSON.stringify(flags),
+      JSON.stringify(keywords),
+      importSource,
+      sourceKey,
+      canonicalPop3MessageSize(rawMessageBytes),
+      receivedAt,
+      insertedAt,
+      insertedAt
+    );
+  if (result.changes) {
+    return { created: true, message: { id: Number(result.lastInsertRowid) } };
+  }
+  const concurrent = requireDb()
+    .prepare('SELECT id FROM inbound_messages WHERE import_source = ? AND import_source_key = ? LIMIT 1')
+    .get(importSource, sourceKey);
+  if (!concurrent) throw new Error('导入邮件写入失败。');
+  return { created: false, message: { id: Number(concurrent.id) } };
+}
+
 export function isDataMigrationComplete(name) {
   return Boolean(requireDb()
     .prepare('SELECT 1 FROM data_migrations WHERE name = ?')
@@ -1261,7 +1448,7 @@ export function listInboundMessages(userId, { mailboxId = null, folder = 'INBOX'
   }
   return requireDb()
     .prepare(`
-      SELECT msg.*, m.address AS mailbox_address, d.domain
+      SELECT ${inboundMessageSummaryColumns('msg')}, m.address AS mailbox_address, d.domain
       FROM inbound_messages msg
       JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
       JOIN domains d ON d.id = msg.domain_id
@@ -1314,7 +1501,7 @@ export function searchInboundMessages(userId, filters = {}, access = {}) {
     .get(...params)?.total || 0);
   const rows = requireDb()
     .prepare(`
-      SELECT msg.*, m.address AS mailbox_address, d.domain
+      SELECT ${inboundMessageSummaryColumns('msg')}, m.address AS mailbox_address, d.domain
       FROM inbound_messages msg
       JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
       JOIN domains d ON d.id = msg.domain_id
@@ -1421,7 +1608,7 @@ export function listInboundMailboxProtocolMessages(mailbox, { folder = 'INBOX' }
   const selectedFolder = normalizeInboundFolder(folder) || 'INBOX';
   return requireDb()
     .prepare(`
-      SELECT msg.*, m.address AS mailbox_address, d.domain
+      SELECT ${inboundMessageSummaryColumns('msg')}, m.address AS mailbox_address, d.domain
       FROM inbound_messages msg
       JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
       JOIN domains d ON d.id = msg.domain_id
@@ -1429,14 +1616,38 @@ export function listInboundMailboxProtocolMessages(mailbox, { folder = 'INBOX' }
       ORDER BY msg.id ASC
     `)
     .all(Number(mailbox.id), mailbox.userId, selectedFolder)
-    .map((row) => publicInboundMessage(row, { includeBody: true, includeRawBytes: true }));
+    .map(publicInboundMessage);
+}
+
+export function getInboundMailboxProtocolMessage(mailbox, messageId, { folder = 'INBOX' } = {}) {
+  if (!mailbox?.id || !mailbox?.userId) return null;
+  const selectedFolder = normalizeInboundFolder(folder) || 'INBOX';
+  const row = requireDb()
+    .prepare(`
+      SELECT msg.*, m.address AS mailbox_address, d.domain
+      FROM inbound_messages msg
+      JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
+      JOIN domains d ON d.id = msg.domain_id
+      WHERE msg.id = ? AND msg.mailbox_id = ? AND msg.user_id = ?
+        AND msg.folder = ? AND msg.deleted_at IS NULL
+      LIMIT 1
+    `)
+    .get(Number(messageId), Number(mailbox.id), mailbox.userId, selectedFolder);
+  return publicInboundMessage(row, { includeRawBytes: true });
 }
 
 export function markInboundMessageRead(userId, id, read = true) {
+  const current = requireDb()
+    .prepare('SELECT flags_json FROM inbound_messages WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
+    .get(Number(id), userId);
+  if (!current) return null;
+  const flags = normalizeImportedStringList(safeJson(current.flags_json, []))
+    .filter((flag) => flag.toLowerCase() !== '\\seen');
+  if (read) flags.push('\\Seen');
   const updatedAt = now();
   const result = requireDb()
-    .prepare('UPDATE inbound_messages SET read_state = ?, updated_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
-    .run(read ? 'true' : 'false', updatedAt, Number(id), userId);
+    .prepare('UPDATE inbound_messages SET read_state = ?, flags_json = ?, updated_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
+    .run(read ? 'true' : 'false', JSON.stringify(flags), updatedAt, Number(id), userId);
   if (!result.changes) return null;
   return getInboundMessage(userId, id);
 }
@@ -3774,6 +3985,37 @@ function ensureColumn(table, column, definition) {
   if (!columnExists(table, column)) requireDb().exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
 }
 
+function backfillInboundPop3Sizes() {
+  if (!tableExists('inbound_messages') || !columnExists('inbound_messages', 'pop3_size')) return;
+  const database = requireDb();
+  const update = database.prepare('UPDATE inbound_messages SET pop3_size = ? WHERE id = ?');
+  database.exec('BEGIN IMMEDIATE');
+  try {
+    for (const row of database.prepare(`
+      SELECT id, COALESCE(raw_message_bytes, CAST(raw_message AS BLOB), X'') AS raw_message_bytes
+      FROM inbound_messages
+      WHERE pop3_size IS NULL OR pop3_size <= 0
+    `).iterate()) {
+      update.run(canonicalPop3MessageSize(row.raw_message_bytes), row.id);
+    }
+    database.exec('COMMIT');
+  } catch (error) {
+    try {
+      database.exec('ROLLBACK');
+    } catch {
+      // ignore rollback errors when no transaction is open
+    }
+    throw error;
+  }
+}
+
+function secureDatabaseStorage(dataDir, databasePath) {
+  chmodSync(dataDir, 0o700);
+  for (const filename of [databasePath, `${databasePath}-wal`, `${databasePath}-shm`]) {
+    if (existsSync(filename)) chmodSync(filename, 0o600);
+  }
+}
+
 function normalizeDkimPublicKeys() {
   if (!tableExists('domains') || !columnExists('domains', 'dkim_private') || !columnExists('domains', 'dkim_public')) return;
   const rows = requireDb().prepare('SELECT id, dkim_public, dkim_private FROM domains').all();
@@ -3880,6 +4122,15 @@ function publicInboundMailbox(row, { includeHash = false, includeSecret = false
 
 function publicInboundMessage(row, { includeBody = false, includeRawBytes = false } = {}) {
   if (!row) return null;
+  const rawMessageSize = row.raw_message_size === undefined
+    ? (row.raw_message_bytes
+        ? Number(row.raw_message_bytes.byteLength || row.raw_message_bytes.length || 0)
+        : Buffer.byteLength(String(row.raw_message || ''), 'utf8'))
+    : Number(row.raw_message_size || 0);
+  const storedPop3MessageSize = Number(row.pop3_size || 0);
+  const pop3MessageSize = storedPop3MessageSize > 0
+    ? storedPop3MessageSize
+    : canonicalPop3MessageSize(row.raw_message_bytes || row.raw_message || '');
   return {
     id: row.id,
     mailboxId: row.mailbox_id,
@@ -3894,20 +4145,50 @@ function publicInboundMessage(row, { includeBody = false, includeRawBytes = fals
     messageId: row.message_id,
     preview: row.preview,
     read: row.read_state === 'true',
+    flags: safeJson(row.flags_json, []),
+    keywords: safeJson(row.keywords_json, []),
+    rawMessageSize,
+    pop3MessageSize,
     receivedAt: row.received_at,
     createdAt: row.created_at,
     updatedAt: row.updated_at,
     ...(includeBody ? {
-      rawMessage: row.raw_message,
+      rawMessage: row.raw_message || (row.raw_message_bytes ? Buffer.from(row.raw_message_bytes).toString('utf8') : ''),
       textBody: row.text_body,
       htmlBody: row.html_body
     } : {}),
     ...(includeRawBytes ? {
-      rawMessageBytes: row.raw_message_bytes ? Buffer.from(row.raw_message_bytes) : Buffer.from(row.raw_message || '', 'utf8')
+      rawMessageBytes: row.raw_message_bytes
+        ? (Buffer.isBuffer(row.raw_message_bytes) ? row.raw_message_bytes : Buffer.from(row.raw_message_bytes))
+        : Buffer.from(row.raw_message || '', 'utf8')
     } : {})
   };
 }
 
+function inboundMessageSummaryColumns(alias) {
+  return [
+    'id',
+    'mailbox_id',
+    'user_id',
+    'domain_id',
+    'folder',
+    'sender',
+    'recipients_json',
+    'subject',
+    'message_id',
+    'preview',
+    'read_state',
+    'flags_json',
+    'keywords_json',
+    'pop3_size',
+    'received_at',
+    'created_at',
+    'updated_at'
+  ].map((column) => `${alias}.${column}`).concat(
+    `COALESCE(length(${alias}.raw_message_bytes), length(CAST(${alias}.raw_message AS BLOB)), 0) AS raw_message_size`
+  ).join(', ');
+}
+
 function publicSmtpCredential(row, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
   if (!row) return null;
   const password = includePassword ? decryptSecret(row.password_secret) : '';
@@ -4369,6 +4650,42 @@ function normalizeInboundFolder(value, fallback = '') {
     .join('/');
 }
 
+function normalizeImportSource(value) {
+  const source = String(value || '').trim().toLowerCase();
+  if (!source || source.length > 120 || !/^[a-z0-9][a-z0-9._:@/-]*$/.test(source)) return '';
+  return source;
+}
+
+function normalizeImportSourceKey(value) {
+  const key = String(value || '').trim();
+  if (!key || key.length > 512 || /[\r\n\u0000]/.test(key)) return '';
+  return key;
+}
+
+function normalizeImportedReceivedAt(value) {
+  const timestamp = Date.parse(String(value || ''));
+  if (!Number.isFinite(timestamp)) throw new Error('导入邮件时间不正确。');
+  return new Date(timestamp).toISOString();
+}
+
+function normalizeImportedStringList(values) {
+  const list = Array.isArray(values) ? values : [values];
+  return [...new Set(list.map((value) => String(value || '').trim()).filter(Boolean))];
+}
+
+function canonicalPop3MessageSize(value) {
+  const bytes = Buffer.isBuffer(value)
+    ? value
+    : value instanceof Uint8Array
+      ? Buffer.from(value)
+      : Buffer.from(String(value || ''), 'utf8');
+  let size = bytes.length;
+  for (let index = 0; index < bytes.length; index += 1) {
+    if (bytes[index] === 0x0a && (index === 0 || bytes[index - 1] !== 0x0d)) size += 1;
+  }
+  return bytes.at(-1) === 0x0a ? size : size + 2;
+}
+
 function inboundFolderSpecialUse(folder) {
   return {
     Sent: '\\Sent',
@@ -4849,19 +5166,6 @@ function tokenHash(token) {
   return crypto.createHash('sha256').update(String(token || '')).digest('hex');
 }
 
-function hashPassword(password) {
-  const salt = crypto.randomBytes(16).toString('hex');
-  const hash = crypto.scryptSync(String(password), salt, 64).toString('hex');
-  return `scrypt$${salt}$${hash}`;
-}
-
-function verifyPassword(password, stored) {
-  const [scheme, salt, hash] = String(stored || '').split('$');
-  if (scheme !== 'scrypt' || !salt || !hash) return false;
-  const actual = crypto.scryptSync(String(password), salt, 64).toString('hex');
-  return safeEqual(actual, hash);
-}
-
 function encryptSecret(value) {
   if (!value) return '';
   const iv = crypto.randomBytes(12);

+ 83 - 0
src/imap-utf7.js

@@ -0,0 +1,83 @@
+export function encodeModifiedUtf7(value) {
+  const source = String(value || '');
+  let output = '';
+  let encodedRun = '';
+
+  const flush = () => {
+    if (!encodedRun) return;
+    const utf16 = Buffer.from(encodedRun, 'utf16le');
+    utf16.swap16();
+    output += `&${modifiedBase64(utf16)}-`;
+    encodedRun = '';
+  };
+
+  for (const character of source) {
+    const codePoint = character.codePointAt(0);
+    if (codePoint >= 0x20 && codePoint <= 0x7e) {
+      flush();
+      output += character === '&' ? '&-' : character;
+    } else {
+      encodedRun += character;
+    }
+  }
+  flush();
+  return output;
+}
+
+export function decodeModifiedUtf7(value) {
+  const source = String(value || '');
+  let output = '';
+  let cursor = 0;
+
+  while (cursor < source.length) {
+    const start = source.indexOf('&', cursor);
+    if (start === -1) return output + source.slice(cursor);
+    output += source.slice(cursor, start);
+    const end = source.indexOf('-', start + 1);
+    if (end === -1) return output + source.slice(start);
+    const encoded = source.slice(start + 1, end);
+    if (!encoded) {
+      output += '&';
+      cursor = end + 1;
+      continue;
+    }
+
+    const decoded = decodeModifiedBase64(encoded);
+    output += decoded === null ? source.slice(start, end + 1) : decoded;
+    cursor = end + 1;
+  }
+  return output;
+}
+
+function decodeModifiedBase64(value) {
+  if (!/^[A-Za-z0-9+,]+$/.test(value) || value.length % 4 === 1) return null;
+  try {
+    const base64 = value.replace(/,/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=');
+    const bytes = Buffer.from(base64, 'base64');
+    if (!bytes.length || bytes.length % 2 || modifiedBase64(bytes) !== value) return null;
+    const utf16 = Buffer.from(bytes);
+    utf16.swap16();
+    const decoded = utf16.toString('utf16le');
+    return hasValidSurrogates(decoded) ? decoded : null;
+  } catch {
+    return null;
+  }
+}
+
+function modifiedBase64(value) {
+  return Buffer.from(value).toString('base64').replace(/\//g, ',').replace(/=+$/g, '');
+}
+
+function hasValidSurrogates(value) {
+  for (let index = 0; index < value.length; index += 1) {
+    const code = value.charCodeAt(index);
+    if (code >= 0xd800 && code <= 0xdbff) {
+      const next = value.charCodeAt(index + 1);
+      if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
+      index += 1;
+    } else if (code >= 0xdc00 && code <= 0xdfff) {
+      return false;
+    }
+  }
+  return true;
+}

+ 80 - 19
src/mail-access.js

@@ -5,6 +5,7 @@ import {
   STANDARD_INBOUND_FOLDERS,
   createInboundFolder,
   createInboundMessage,
+  getInboundMailboxProtocolMessage,
   inboundFolderExists,
   listInboundFolders,
   listInboundMailboxProtocolMessages,
@@ -13,6 +14,8 @@ import {
   verifyInboundMailboxCredential
 } from './db.js';
 import { parseInboundMessage } from './inbound-mail.js';
+import { decodeModifiedUtf7, encodeModifiedUtf7 } from './imap-utf7.js';
+import { authenticateWithRateLimit, authenticationRateLimiter } from './auth-rate-limit.js';
 
 export function startMailboxAccessServers(config) {
   const tlsMaterial = loadTlsMaterial(config);
@@ -84,6 +87,8 @@ class ImapSession {
     this.config = config;
     this.buffer = Buffer.alloc(0);
     this.authenticated = false;
+    this.authRateLimiter = config.authRateLimiter || authenticationRateLimiter;
+    this.remoteAddress = socket.remoteAddress || '';
     this.user = null;
     this.mailbox = null;
     this.selectedFolder = 'INBOX';
@@ -203,7 +208,7 @@ class ImapSession {
     if (!this.canAuthenticate()) return this.write(`${tag} NO Encryption required for authentication`);
     const [username, password] = tokenizeImap(rest);
     if (!username || password === undefined) return this.write(`${tag} BAD LOGIN expects username and password`);
-    const auth = verifyInboundMailboxCredential(username, password);
+    const auth = this.verifyCredential(username, password);
     if (!auth) return this.write(`${tag} NO Authentication failed`);
     this.user = auth.user;
     this.mailbox = auth.mailbox;
@@ -225,7 +230,7 @@ class ImapSession {
     const parts = decoded.split('\u0000');
     const username = parts[1] || parts[0] || '';
     const password = parts[2] || parts[1] || '';
-    const auth = verifyInboundMailboxCredential(username, password);
+    const auth = this.verifyCredential(username, password);
     if (!auth) return this.write(`${tag} NO Authentication failed`);
     this.user = auth.user;
     this.mailbox = auth.mailbox;
@@ -235,7 +240,7 @@ class ImapSession {
 
   list(tag) {
     for (const folder of listInboundFolders(this.mailbox)) {
-      this.write(`* LIST (${imapFolderAttributes(folder).join(' ')}) "/" ${imapNString(folder)}`);
+      this.write(`* LIST (${imapFolderAttributes(folder).join(' ')}) "/" ${imapNString(encodeModifiedUtf7(folder))}`);
     }
     this.write(`${tag} OK LIST completed`);
   }
@@ -252,7 +257,7 @@ class ImapSession {
     this.selectedFolder = folder;
     this.reloadMessages();
     this.selected = true;
-    this.write('* FLAGS (\\Seen \\Deleted)');
+    this.write(`* FLAGS (${imapAdvertisedFlags(this.messages).join(' ')})`);
     this.write(`* ${this.messages.length} EXISTS`);
     this.write('* 0 RECENT');
     this.write(`* OK [UIDVALIDITY ${this.mailbox.id}] UIDs valid`);
@@ -267,7 +272,7 @@ class ImapSession {
     if (!inboundFolderExists(this.mailbox, folder)) return this.write(`${tag} NO Mailbox does not exist`);
     const messages = mailboxProtocolMessages(this.mailbox, folder);
     const unseen = messages.filter((message) => !message.read).length;
-    this.write(`* STATUS ${imapNString(folder)} (MESSAGES ${messages.length} UNSEEN ${unseen} UIDNEXT ${uidNext(messages)} UIDVALIDITY ${this.mailbox.id})`);
+    this.write(`* STATUS ${imapNString(encodeModifiedUtf7(folder))} (MESSAGES ${messages.length} UNSEEN ${unseen} UIDNEXT ${uidNext(messages)} UIDVALIDITY ${this.mailbox.id})`);
     this.write(`${tag} OK STATUS completed`);
   }
 
@@ -338,15 +343,18 @@ class ImapSession {
 
   sendFetch(entry, items, byUid) {
     const upper = String(items || '').toUpperCase();
+    const contentMessage = fetchNeedsRawMessage(items)
+      ? loadProtocolMessage(this.mailbox, this.selectedFolder, entry.message)
+      : entry.message;
     const attrs = [];
     if (byUid || /\bUID\b/.test(upper)) attrs.push(`UID ${entry.message.id}`);
     if (!upper || /\bFLAGS\b/.test(upper)) attrs.push(`FLAGS (${imapFlags(entry.message, this.deletedUids).join(' ')})`);
     if (/\bINTERNALDATE\b/.test(upper)) attrs.push(`INTERNALDATE "${imapDate(entry.message.receivedAt)}"`);
     if (/RFC822\.SIZE|RFC822|BODY(?:\.PEEK)?\[/i.test(items)) attrs.push(`RFC822.SIZE ${messageBytes(entry.message)}`);
     if (/\bENVELOPE\b/.test(upper)) attrs.push(`ENVELOPE ${imapEnvelope(entry.message)}`);
-    if (/\bBODYSTRUCTURE\b/.test(upper)) attrs.push(`BODYSTRUCTURE ${imapBodyStructure(entry.message)}`);
+    if (/\bBODYSTRUCTURE\b/.test(upper)) attrs.push(`BODYSTRUCTURE ${imapBodyStructure(contentMessage)}`);
 
-    const literal = resolveFetchLiteral(items, entry.message);
+    const literal = resolveFetchLiteral(items, contentMessage);
     if (!literal) {
       this.write(`* ${entry.seq} FETCH (${attrs.join(' ')})`);
       return;
@@ -429,6 +437,15 @@ class ImapSession {
     return this.config.tlsActive || this.config.allowInsecureAuth;
   }
 
+  verifyCredential(username, password) {
+    return authenticateWithRateLimit({
+      limiter: this.authRateLimiter,
+      ip: this.remoteAddress,
+      account: username,
+      authenticate: () => verifyInboundMailboxCredential(username, password)
+    });
+  }
+
   write(line) {
     this.socket.write(`${line}\r\n`);
   }
@@ -441,6 +458,8 @@ class Pop3Session {
     this.buffer = '';
     this.username = '';
     this.authenticated = false;
+    this.authRateLimiter = config.authRateLimiter || authenticationRateLimiter;
+    this.remoteAddress = socket.remoteAddress || '';
     this.user = null;
     this.mailbox = null;
     this.messages = [];
@@ -515,6 +534,7 @@ class Pop3Session {
   }
 
   auth(rest) {
+    if (!this.canAuthenticate()) return this.write('-ERR Encryption required for authentication');
     const [method, response] = rest.split(/\s+/, 2);
     if (String(method || '').toUpperCase() !== 'PLAIN' || !response) return this.write('-ERR Unsupported authentication method');
     const parts = decodeBase64(response).split('\u0000');
@@ -522,7 +542,12 @@ class Pop3Session {
   }
 
   finishAuth(username, password) {
-    const auth = verifyInboundMailboxCredential(username, password);
+    const auth = authenticateWithRateLimit({
+      limiter: this.authRateLimiter,
+      ip: this.remoteAddress,
+      account: username,
+      authenticate: () => verifyInboundMailboxCredential(username, password)
+    });
     if (!auth) return this.write('-ERR Authentication failed');
     this.user = auth.user;
     this.mailbox = auth.mailbox;
@@ -562,7 +587,7 @@ class Pop3Session {
   retr(rest) {
     const entry = this.messageByNumber(rest);
     if (!entry) return this.write('-ERR No such message');
-    const rawMessage = pop3RawMessageBytes(entry.message);
+    const rawMessage = pop3RawMessageBytes(loadProtocolMessage(this.mailbox, 'INBOX', entry.message));
     this.write(`+OK ${rawMessage.length} octets`);
     this.socket.write(dotStuffBytes(rawMessage));
     this.socket.write('.\r\n');
@@ -573,8 +598,9 @@ class Pop3Session {
     const entry = this.messageByNumber(messageNumber);
     if (!entry) return this.write('-ERR No such message');
     const lineCount = Math.max(0, Number(lineCountRaw || 0) || 0);
+    const message = loadProtocolMessage(this.mailbox, 'INBOX', entry.message);
     const preview = Buffer.from(
-      topLines(pop3RawMessageBytes(entry.message).toString('latin1'), lineCount),
+      topLines(pop3RawMessageBytes(message).toString('latin1'), lineCount),
       'latin1'
     );
     this.write('+OK Top of message follows');
@@ -659,10 +685,15 @@ function publicProtocolLabel(protocol, tlsEnabled) {
 }
 
 function mailboxProtocolMessages(mailbox, folder = 'INBOX') {
-  return listInboundMailboxProtocolMessages(mailbox, { folder }).map((message) => ({
-    ...message,
-    rawMessage: normalizeRawMessage(message)
-  }));
+  return listInboundMailboxProtocolMessages(mailbox, { folder });
+}
+
+function loadProtocolMessage(mailbox, folder, summary) {
+  return getInboundMailboxProtocolMessage(mailbox, summary.id, { folder }) || summary;
+}
+
+function fetchNeedsRawMessage(items) {
+  return /\bBODYSTRUCTURE\b|\bRFC822\b(?!\.SIZE)|BODY(?:\.PEEK)?\[/i.test(String(items || ''));
 }
 
 function tokenizeImap(value) {
@@ -732,7 +763,7 @@ function splitFirst(value) {
 }
 
 function normalizeImapFolder(value) {
-  const raw = String(value || '').trim().replace(/^"|"$/g, '').replace(/\\/g, '/');
+  const raw = decodeModifiedUtf7(String(value || '').trim().replace(/^"|"$/g, '')).replace(/\\/g, '/');
   if (!raw || /[\r\n\u0000]/.test(raw)) return '';
   if (raw.toUpperCase() === 'INBOX') return 'INBOX';
   const standard = STANDARD_INBOUND_FOLDERS.find((folder) => folder.toLowerCase() === raw.toLowerCase());
@@ -957,10 +988,38 @@ function parseFlags(value) {
 }
 
 function imapFlags(message, deletedUids) {
-  return [
-    message.read ? '\\Seen' : '',
-    deletedUids.has(message.id) ? '\\Deleted' : ''
-  ].filter(Boolean);
+  const stored = [...(message.flags || []), ...(message.keywords || [])]
+    .map(normalizeImapFlag)
+    .filter((flag) => flag && flag.toLowerCase() !== '\\seen');
+  if (message.read) stored.push('\\Seen');
+  if (deletedUids.has(message.id)) stored.push('\\Deleted');
+  return uniqueImapFlags(stored);
+}
+
+function imapAdvertisedFlags(messages) {
+  const stored = messages.flatMap((message) => [
+    ...(message.flags || []),
+    ...(message.keywords || [])
+  ]);
+  return uniqueImapFlags([
+    '\\Seen',
+    '\\Answered',
+    '\\Flagged',
+    '\\Deleted',
+    '\\Draft',
+    ...stored
+  ].map(normalizeImapFlag).filter(Boolean));
+}
+
+function normalizeImapFlag(value) {
+  const flag = String(value || '').trim();
+  if (/^\\[A-Za-z][A-Za-z0-9._-]*$/.test(flag)) return flag;
+  if (/^[^\x00-\x20\x7f(){%*"\\\]]+$/.test(flag)) return flag;
+  return '';
+}
+
+function uniqueImapFlags(flags) {
+  return [...new Map(flags.map((flag) => [flag.toLowerCase(), flag])).values()];
 }
 
 function imapEnvelope(message) {
@@ -989,10 +1048,12 @@ function uidNext(messages) {
 }
 
 function messageBytes(message) {
+  if (Number.isFinite(Number(message.rawMessageSize))) return Number(message.rawMessageSize);
   return exactRawMessageBytes(message).length;
 }
 
 function pop3MessageBytes(message) {
+  if (Number.isFinite(Number(message.pop3MessageSize))) return Number(message.pop3MessageSize);
   return pop3RawMessageBytes(message).length;
 }
 

+ 104 - 0
src/password-hash.js

@@ -0,0 +1,104 @@
+import crypto from 'node:crypto';
+
+const MD5_CRYPT_ALPHABET = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
+
+export function hashPassword(password) {
+  const salt = crypto.randomBytes(16).toString('hex');
+  const hash = crypto.scryptSync(String(password), salt, 64).toString('hex');
+  return `scrypt$${salt}$${hash}`;
+}
+
+export function verifyPassword(password, stored) {
+  if (isLegacyPasswordHash(stored)) return verifyVestaPassword(password, stored);
+  return verifyScryptPassword(password, stored);
+}
+
+export function verifyScryptPassword(password, stored) {
+  const parts = String(stored || '').split('$');
+  if (parts.length !== 3) return false;
+  const [scheme, salt, expectedHex] = parts;
+  if (scheme !== 'scrypt' || !salt || !/^[0-9a-f]{128}$/i.test(expectedHex)) return false;
+  const actual = crypto.scryptSync(String(password), salt, 64);
+  return safeEqual(actual, Buffer.from(expectedHex, 'hex'));
+}
+
+export function isLegacyPasswordHash(stored) {
+  return Boolean(parseVestaPasswordHash(stored));
+}
+
+export function verifyVestaPassword(password, stored) {
+  const parsed = parseVestaPasswordHash(stored);
+  if (!parsed) return false;
+  const actual = md5Crypt(String(password), parsed.salt);
+  return safeEqual(Buffer.from(actual), Buffer.from(parsed.hash));
+}
+
+function parseVestaPasswordHash(stored) {
+  const match = String(stored || '').match(
+    /^(?:\{(?:MD5|MD5-CRYPT)\})?(\$1\$([./0-9A-Za-z]{1,8})\$[./0-9A-Za-z]{22})$/i
+  );
+  if (!match) return null;
+  return { hash: match[1], salt: match[2] };
+}
+
+function md5Crypt(password, salt) {
+  const passwordBytes = Buffer.from(password, 'utf8');
+  const saltBytes = Buffer.from(salt, 'ascii');
+  const magicBytes = Buffer.from('$1$', 'ascii');
+  const initialParts = [passwordBytes, magicBytes, saltBytes];
+  const alternate = md5([passwordBytes, saltBytes, passwordBytes]);
+
+  for (let remaining = passwordBytes.length; remaining > 0; remaining -= 16) {
+    initialParts.push(alternate.subarray(0, Math.min(16, remaining)));
+  }
+  for (let remaining = passwordBytes.length; remaining > 0; remaining >>= 1) {
+    initialParts.push(remaining & 1 ? Buffer.from([0]) : passwordBytes.subarray(0, 1));
+  }
+
+  let digest = md5(initialParts);
+  for (let index = 0; index < 1000; index += 1) {
+    const roundParts = [index & 1 ? passwordBytes : digest];
+    if (index % 3 !== 0) roundParts.push(saltBytes);
+    if (index % 7 !== 0) roundParts.push(passwordBytes);
+    roundParts.push(index & 1 ? digest : passwordBytes);
+    digest = md5(roundParts);
+  }
+
+  return `$1$${salt}$${encodeMd5CryptDigest(digest)}`;
+}
+
+function md5(parts) {
+  const hash = crypto.createHash('md5');
+  for (const part of parts) hash.update(part);
+  return hash.digest();
+}
+
+function encodeMd5CryptDigest(digest) {
+  return [
+    cryptBase64(digest[0], digest[6], digest[12], 4),
+    cryptBase64(digest[1], digest[7], digest[13], 4),
+    cryptBase64(digest[2], digest[8], digest[14], 4),
+    cryptBase64(digest[3], digest[9], digest[15], 4),
+    cryptBase64(digest[4], digest[10], digest[5], 4),
+    cryptBase64(0, 0, digest[11], 2)
+  ].join('');
+}
+
+function cryptBase64(high, middle, low, length) {
+  let value = (high << 16) | (middle << 8) | low;
+  let result = '';
+  for (let index = 0; index < length; index += 1) {
+    result += MD5_CRYPT_ALPHABET[value & 0x3f];
+    value >>>= 6;
+  }
+  return result;
+}
+
+function safeEqual(actual, expected) {
+  const length = Math.max(actual.length, expected.length);
+  const actualPadded = Buffer.alloc(length);
+  const expectedPadded = Buffer.alloc(length);
+  actual.copy(actualPadded);
+  expected.copy(expectedPadded);
+  return crypto.timingSafeEqual(actualPadded, expectedPadded) && actual.length === expected.length;
+}

+ 31 - 1
src/submission.js

@@ -30,6 +30,7 @@ import {
   stripRawMimeHeaders,
   trackingTargetFingerprint
 } from './tracking.js';
+import { authenticateWithRateLimit, authenticationRateLimiter } from './auth-rate-limit.js';
 
 const implicitTlsDetectTimeoutMs = 300;
 const defaultMaxMessageBytes = 50 * 1024 * 1024;
@@ -215,7 +216,9 @@ class SubmissionSession {
     this.dataLines = [];
     this.authState = '';
     this.authUser = '';
+    this.authRateLimiter = config.authRateLimiter || authenticationRateLimiter;
     this.user = null;
+    this.authMailbox = null;
     this.authenticated = false;
     this.mailFrom = '';
     this.mailFromAccepted = false;
@@ -312,6 +315,7 @@ class SubmissionSession {
     this.buffer = '';
     this.authenticated = false;
     this.user = null;
+    this.authMailbox = null;
     this.authState = '';
     this.config = {
       ...this.config,
@@ -365,13 +369,20 @@ class SubmissionSession {
   }
 
   finishAuth(user, password) {
-    const auth = verifySmtpCredential(user, password);
+    const auth = authenticateWithRateLimit({
+      limiter: this.authRateLimiter,
+      ip: this.remoteAddress,
+      account: user,
+      authenticate: () => verifySmtpCredential(user, password)
+    });
     if (auth?.user) {
       this.user = auth.user;
+      this.authMailbox = auth.mailbox || null;
       this.authenticated = true;
       return this.write(235, 'Authentication successful');
     }
     this.user = null;
+    this.authMailbox = null;
     this.authenticated = false;
     return this.write(535, 'Authentication failed');
   }
@@ -380,6 +391,9 @@ class SubmissionSession {
     if (!this.authenticated && !this.config.inboundEnabled) return this.write(530, 'Authentication required');
     const address = extractPathAddress(argument, { allowEmpty: !this.authenticated });
     if (address === null) return this.write(501, 'Invalid MAIL FROM');
+    if (this.authMailbox && !mailboxAllowsSender(this.authMailbox, address)) {
+      return this.write(553, 'Sender address is not allowed for this mailbox');
+    }
     this.mailFrom = address;
     this.mailFromAccepted = true;
     this.recipients = [];
@@ -427,6 +441,10 @@ class SubmissionSession {
     const headerFrom = extractHeader(rawMessage, 'from');
     const subject = decodeHeader(extractHeader(rawMessage, 'subject')) || '(no subject)';
     const sender = extractAddress(headerFrom) || this.mailFrom;
+    if (this.authMailbox && !mailboxAllowsSender(this.authMailbox, sender)) {
+      this.resetEnvelope(false);
+      return this.write(553, 'From address is not allowed for this mailbox');
+    }
     const domainName = domainFromAddress(sender || this.mailFrom);
     const domain = getDomainByName(domainName, { userId: this.user?.id, includePrivate: true });
     if (!domain) {
@@ -642,6 +660,18 @@ function extractPathAddress(argument, { allowEmpty = false } = {}) {
   return extractAddress(raw) || null;
 }
 
+function mailboxAllowsSender(mailbox, address) {
+  const sender = extractAddress(address);
+  const domain = String(mailbox?.domain || '').trim().toLowerCase();
+  if (!sender || !domain) return false;
+  const allowed = new Set([String(mailbox.address || '').trim().toLowerCase()]);
+  for (const alias of mailbox.aliases || []) {
+    const localPart = String(alias || '').trim().toLowerCase();
+    if (localPart && !localPart.includes('@')) allowed.add(`${localPart}@${domain}`);
+  }
+  return allowed.has(sender);
+}
+
 function extractHeader(rawMessage, name) {
   const head = rawMessage.split(/\r?\n\r?\n/, 1)[0] || '';
   const lines = head.split(/\r?\n/);

+ 583 - 0
src/vesta-maildir-import.js

@@ -0,0 +1,583 @@
+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 导入已取消。');
+}

+ 149 - 0
test/auth-rate-limit.test.js

@@ -0,0 +1,149 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import {
+  AuthenticationRateLimiter,
+  authenticateWithRateLimit,
+  defaultAuthenticationRateLimits,
+  normalizeAuthenticationAccount,
+  normalizeAuthenticationIp
+} from '../src/auth-rate-limit.js';
+
+test('authentication rate limiter uses production thresholds and normalizes identities', () => {
+  assert.deepEqual(defaultAuthenticationRateLimits, {
+    windowMs: 600_000,
+    blockMs: 600_000,
+    combinationLimit: 10,
+    accountLimit: 30,
+    ipLimit: 100,
+    maxEntries: 50_000,
+    cleanupIntervalMs: 60_000
+  });
+  assert.equal(normalizeAuthenticationIp('::ffff:192.0.2.10'), '192.0.2.10');
+  assert.equal(normalizeAuthenticationIp('[2001:DB8::1]'), '2001:db8::1');
+  assert.equal(normalizeAuthenticationAccount('  Admin@Example.COM  '), 'admin@example.com');
+});
+
+test('authentication rate limiter blocks an IP and account combination for the configured duration', () => {
+  let now = 1_000;
+  const limiter = new AuthenticationRateLimiter({
+    now: () => now,
+    windowMs: 10_000,
+    blockMs: 20_000,
+    combinationLimit: 3,
+    accountLimit: 10,
+    ipLimit: 20
+  });
+  const identity = { ip: '192.0.2.1', account: 'user@example.com' };
+
+  limiter.recordFailure(identity);
+  limiter.recordFailure(identity);
+  assert.equal(limiter.isBlocked(identity), false);
+  assert.equal(limiter.recordFailure(identity), true);
+  assert.equal(limiter.isBlocked(identity), true);
+
+  now += 19_999;
+  assert.equal(limiter.isBlocked(identity), true);
+  now += 1;
+  assert.equal(limiter.isBlocked(identity), false);
+});
+
+test('authentication rate limiter aggregates failures by account across IPs', () => {
+  const limiter = new AuthenticationRateLimiter({
+    combinationLimit: 10,
+    accountLimit: 3,
+    ipLimit: 20
+  });
+  const account = 'user@example.com';
+
+  limiter.recordFailure({ ip: '192.0.2.1', account });
+  limiter.recordFailure({ ip: '192.0.2.2', account });
+  limiter.recordFailure({ ip: '192.0.2.3', account });
+
+  assert.equal(limiter.isBlocked({ ip: '192.0.2.99', account }), true);
+  assert.equal(limiter.isBlocked({ ip: '192.0.2.99', account: 'other@example.com' }), false);
+});
+
+test('authentication rate limiter aggregates failures by IP across accounts', () => {
+  const limiter = new AuthenticationRateLimiter({
+    combinationLimit: 10,
+    accountLimit: 10,
+    ipLimit: 3
+  });
+  const ip = '192.0.2.1';
+
+  limiter.recordFailure({ ip, account: 'one@example.com' });
+  limiter.recordFailure({ ip, account: 'two@example.com' });
+  limiter.recordFailure({ ip, account: 'three@example.com' });
+
+  assert.equal(limiter.isBlocked({ ip, account: 'four@example.com' }), true);
+  assert.equal(limiter.isBlocked({ ip: '192.0.2.2', account: 'four@example.com' }), false);
+});
+
+test('successful authentication clears account records without clearing unrelated IP failures', () => {
+  const limiter = new AuthenticationRateLimiter({
+    combinationLimit: 5,
+    accountLimit: 5,
+    ipLimit: 3
+  });
+  const ip = '192.0.2.1';
+  const account = 'user@example.com';
+
+  limiter.recordFailure({ ip, account });
+  limiter.recordFailure({ ip, account });
+  const result = authenticateWithRateLimit({
+    limiter,
+    ip,
+    account,
+    authenticate: () => ({ id: 1 })
+  });
+  assert.deepEqual(result, { id: 1 });
+
+  limiter.recordFailure({ ip, account: 'second@example.com' });
+  assert.equal(limiter.isBlocked({ ip, account: 'third@example.com' }), true);
+  assert.equal(limiter.isBlocked({ ip: '192.0.2.2', account }), false);
+});
+
+test('rate-limited authentication skips credential verification while keeping storage bounded', () => {
+  let now = 1_000;
+  const limiter = new AuthenticationRateLimiter({
+    now: () => now,
+    windowMs: 100,
+    blockMs: 100,
+    combinationLimit: 1,
+    accountLimit: 100,
+    ipLimit: 100,
+    maxEntries: 5,
+    cleanupIntervalMs: 1
+  });
+  let verifications = 0;
+
+  assert.equal(authenticateWithRateLimit({
+    limiter,
+    ip: '192.0.2.1',
+    account: 'blocked@example.com',
+    authenticate: () => {
+      verifications += 1;
+      return null;
+    }
+  }), null);
+  assert.equal(authenticateWithRateLimit({
+    limiter,
+    ip: '192.0.2.1',
+    account: 'blocked@example.com',
+    authenticate: () => {
+      verifications += 1;
+      return { id: 1 };
+    }
+  }), null);
+  assert.equal(verifications, 1);
+
+  for (let index = 0; index < 20; index += 1) {
+    limiter.recordFailure({ ip: `198.51.100.${index}`, account: `user-${index}@example.com` });
+    assert.ok(limiter.entryCount <= 5);
+  }
+
+  now += 201;
+  limiter.isBlocked({ ip: '203.0.113.1', account: 'fresh@example.com' });
+  assert.equal(limiter.entryCount, 0);
+});

+ 25 - 1
test/db.test.js

@@ -1,6 +1,6 @@
 import assert from 'node:assert/strict';
 import { createHash } from 'node:crypto';
-import { mkdtempSync } from 'node:fs';
+import { chmodSync, mkdtempSync, statSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
 import { test } from 'node:test';
@@ -172,6 +172,13 @@ test('migrates legacy inbound messages before creating folder indexes', () => {
       deleted_at TEXT
     );
   `);
+  const legacyRawMessage = 'Subject: legacy\n\nbody';
+  legacy.prepare(`
+    INSERT INTO inbound_messages (
+      mailbox_id, user_id, domain_id, sender, recipients_json, subject, message_id,
+      raw_message, text_body, html_body, preview, read_state, received_at, created_at, updated_at
+    ) VALUES (1, 1, 1, '', '[]', 'legacy', '', ?, 'body', '', 'body', 'false', 'now', 'now', 'now')
+  `).run(legacyRawMessage);
   legacy.close();
 
   const database = initDatabase(dataDir, 'test-secret');
@@ -179,7 +186,24 @@ test('migrates legacy inbound messages before creating folder indexes', () => {
   const indexes = database.prepare('PRAGMA index_list(inbound_messages)').all().map((index) => index.name);
 
   assert.ok(columns.includes('folder'));
+  assert.ok(columns.includes('pop3_size'));
   assert.ok(indexes.includes('idx_inbound_messages_mailbox_folder_received'));
+  assert.equal(
+    Number(database.prepare('SELECT pop3_size FROM inbound_messages WHERE id = 1').get().pop3_size),
+    Buffer.byteLength('Subject: legacy\r\n\r\nbody\r\n')
+  );
+});
+
+test('restricts the data directory and SQLite files to the service account', { skip: process.platform === 'win32' }, () => {
+  const dataDir = tempDataDir();
+  chmodSync(dataDir, 0o755);
+
+  initDatabase(dataDir, 'test-secret');
+
+  assert.equal(statSync(dataDir).mode & 0o777, 0o700);
+  for (const suffix of ['', '-wal', '-shm']) {
+    assert.equal(statSync(path.join(dataDir, `mailhub.sqlite${suffix}`)).mode & 0o777, 0o600);
+  }
 });
 
 test('migrates legacy send events with engagement tracking disabled', () => {

+ 25 - 0
test/imap-utf7.test.js

@@ -0,0 +1,25 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { decodeModifiedUtf7, encodeModifiedUtf7 } from '../src/imap-utf7.js';
+
+test('Modified UTF-7 keeps printable ASCII and escapes ampersands', () => {
+  assert.equal(encodeModifiedUtf7('INBOX/Projects 2026'), 'INBOX/Projects 2026');
+  assert.equal(encodeModifiedUtf7('R&D'), 'R&-D');
+  assert.equal(decodeModifiedUtf7('R&-D'), 'R&D');
+});
+
+test('Modified UTF-7 encodes Unicode mailbox names as UTF-16BE', () => {
+  assert.equal(encodeModifiedUtf7('中文'), '&Ti1lhw-');
+  assert.equal(decodeModifiedUtf7('&Ti1lhw-'), '中文');
+  assert.equal(decodeModifiedUtf7('~peter/mail/&U,BTFw-/&ZeVnLIqe-'), '~peter/mail/台北/日本語');
+
+  const mailbox = '归档/客户 📧 & 2026';
+  assert.equal(decodeModifiedUtf7(encodeModifiedUtf7(mailbox)), mailbox);
+});
+
+test('Modified UTF-7 preserves malformed encoded sections', () => {
+  assert.equal(decodeModifiedUtf7('Broken &Ti1lhw'), 'Broken &Ti1lhw');
+  assert.equal(decodeModifiedUtf7('Broken &!!!!- name'), 'Broken &!!!!- name');
+  assert.equal(decodeModifiedUtf7('Broken &2AA- name'), 'Broken &2AA- name');
+});

+ 151 - 9
test/mail-access.test.js

@@ -7,19 +7,22 @@ import { test } from 'node:test';
 
 import {
   createDomain,
+  createInboundFolder,
   createInboundMailbox,
   createInboundMessage,
+  createImportedInboundMessage,
   createUser,
   getInboundMessage,
+  inboundFolderExists,
   initDatabase,
   listInboundMessages
 } from '../src/db.js';
 import { startMailboxAccessServers } from '../src/mail-access.js';
 
-test('IMAP clients can log in and fetch mailbox messages', async () => {
-  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-test-')), 'mail-access-secret');
+test('IMAP SELECT keeps message bodies lazy and FETCH hydrates one message', async () => {
+  const database = initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-test-')), 'mail-access-secret');
   const { user, mailbox } = createMailboxFixture('imap.example', 'imap-user');
-  createInboundMessage(mailbox, {
+  const storedMessage = createInboundMessage(mailbox, {
     sender: 'alice@example.net',
     recipients: ['admin@imap.example'],
     subject: 'IMAP hello',
@@ -52,11 +55,15 @@ test('IMAP clients can log in and fetch mailbox messages', async () => {
     assert.match(await client.command('A1 LOGIN "admin@imap.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
     const selected = await client.command('A2 SELECT INBOX', /A2 OK/);
     assert.match(selected, /\* 1 EXISTS/);
+    database
+      .prepare('UPDATE inbound_messages SET raw_message_bytes = ? WHERE id = ?')
+      .run(Buffer.from(storedMessage.rawMessage.replace('Hello through IMAP.', 'Hallo through IMAP.'), 'utf8'), storedMessage.id);
     const fetched = await client.command('A3 UID FETCH 1:* (UID FLAGS RFC822.SIZE BODY.PEEK[])', /A3 OK/);
     assert.match(fetched, /\* 1 FETCH/);
     assert.match(fetched, /UID 1/);
     assert.match(fetched, /Subject: IMAP hello/);
-    assert.match(fetched, /Hello through IMAP\./);
+    assert.match(fetched, /Hallo through IMAP\./);
+    assert.doesNotMatch(fetched, /Hello through IMAP\./);
     await client.command('A4 LOGOUT', /A4 OK/);
     client.close();
     assert.equal(listInboundMessages(user.id).length, 1);
@@ -65,6 +72,52 @@ test('IMAP clients can log in and fetch mailbox messages', async () => {
   }
 });
 
+test('IMAP exposes imported Maildir flags and Dovecot keywords', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-flags-test-')), 'mail-access-secret');
+  const { mailbox } = createMailboxFixture('flags.example', 'flags-user');
+  createImportedInboundMessage(mailbox, {
+    importSource: 'vesta:flags',
+    sourceKey: 'message-1',
+    sender: 'sender@example.net',
+    recipients: ['admin@flags.example'],
+    subject: 'Imported flags',
+    messageId: '<flags@example.net>',
+    rawMessageBytes: Buffer.from('From: sender@example.net\r\nTo: admin@flags.example\r\nSubject: Imported flags\r\n\r\nBody', 'utf8'),
+    flags: ['\\Answered', '\\Flagged', '\\Draft', '\\Seen'],
+    keywords: ['$Label1', 'custom-keyword'],
+    receivedAt: '2024-01-02T03:04:05.000Z'
+  });
+
+  const [server] = startMailboxAccessServers({
+    hostname: 'mail.flags.example',
+    imapEnabled: true,
+    imapListeners: [{ port: 0, protocol: 'imap' }],
+    pop3Enabled: false,
+    pop3Listeners: [],
+    allowInsecureAuth: true
+  });
+  await waitForListening(server);
+
+  let client;
+  try {
+    client = await connectClient(server.address().port);
+    await client.readUntil(/\* OK .* IMAP ready\r\n/);
+    await client.command('A1 LOGIN "admin@flags.example" "mailbox-pass-123"', /A1 OK/);
+    const selected = await client.command('A2 SELECT INBOX', /A2 OK/);
+    assert.match(selected, /\* FLAGS \([^\r\n]*\\Answered/);
+    assert.match(selected, /\* FLAGS \([^\r\n]*\$Label1/);
+    assert.match(selected, /\* FLAGS \([^\r\n]*custom-keyword/);
+    const fetched = await client.command('A3 UID FETCH 1:* (UID FLAGS)', /A3 OK/);
+    for (const flag of ['\\Answered', '\\Flagged', '\\Draft', '\\Seen', '$Label1', 'custom-keyword']) {
+      assert.ok(fetched.includes(flag));
+    }
+    await client.command('A4 LOGOUT', /A4 OK/);
+  } finally {
+    client?.close();
+    await closeServer(server);
+  }
+});
+
 test('IMAP exposes MIME body structures and individual parts for Roundcube', async () => {
   initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-mime-test-')), 'mail-access-secret');
   const { mailbox } = createMailboxFixture('mime.example', 'mime-user');
@@ -171,6 +224,62 @@ test('IMAP exposes standard folders expected by mainstream clients', async () =>
   }
 });
 
+test('IMAP uses Modified UTF-7 on the wire while storing Unicode folder names', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-utf7-test-')), 'mail-access-secret');
+  const { user, mailbox } = createMailboxFixture('utf7.example', 'utf7-user');
+  createInboundFolder(mailbox, '中文 & 项目');
+
+  const [server] = startMailboxAccessServers({
+    hostname: 'mail.utf7.example',
+    imapEnabled: true,
+    imapListeners: [{ port: 0, protocol: 'imap' }],
+    pop3Enabled: false,
+    pop3Listeners: [],
+    allowInsecureAuth: true
+  });
+  await waitForListening(server);
+
+  let client;
+  try {
+    client = await connectClient(server.address().port);
+    await client.readUntil(/\* OK .* IMAP ready\r\n/);
+    await client.command('A1 LOGIN "admin@utf7.example" "mailbox-pass-123"', /A1 OK/);
+
+    const listed = await client.command('A2 LIST "" "*"', /A2 OK/);
+    assert.match(listed, /"&Ti1lhw- &- &mHl27g-"/);
+    assert.doesNotMatch(listed, /中文|项目/);
+    const subscribed = await client.command('A2L LSUB "" "*"', /A2L OK/);
+    assert.match(subscribed, /"&Ti1lhw- &- &mHl27g-"/);
+
+    const selected = await client.command('A3 SELECT "&Ti1lhw- &- &mHl27g-"', /A3 OK/);
+    assert.match(selected, /\* 0 EXISTS/);
+    const status = await client.command('A4 STATUS "&Ti1lhw- &- &mHl27g-" \(MESSAGES UNSEEN\)', /A4 OK/);
+    assert.match(status, /\* STATUS "&Ti1lhw- &- &mHl27g-" \(MESSAGES 0 UNSEEN 0/);
+
+    await client.command('A5 CREATE "&ZeVnLIqe-"', /A5 OK/);
+    assert.equal(inboundFolderExists(mailbox, '日本語'), true);
+    const rawMessage = [
+      'From: Bob <bob@example.net>',
+      'To: admin@utf7.example',
+      'Subject: UTF-7 folder append',
+      '',
+      'Imported into a Unicode folder.'
+    ].join('\r\n');
+    await client.append(
+      `A6 APPEND "&ZeVnLIqe-" {${Buffer.byteLength(rawMessage, 'utf8')}}`,
+      rawMessage,
+      /A6 OK/
+    );
+    assert.equal(listInboundMessages(user.id, { folder: '日本語' }).length, 1);
+
+    await client.command('A7 LOGOUT', /A7 OK/);
+    client.close();
+  } finally {
+    client?.close();
+    await closeServer(server);
+  }
+});
+
 test('IMAP APPEND stores sent messages in the Sent folder', async () => {
   initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-append-test-')), 'mail-access-secret');
   const { user } = createMailboxFixture('append.example', 'append-user');
@@ -261,7 +370,7 @@ test('IMAP APPEND stores sent messages in the Sent folder', async () => {
 });
 
 test('POP3 clients can retrieve and delete messages on quit', async () => {
-  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-pop3-test-')), 'mail-access-secret');
+  const database = initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-pop3-test-')), 'mail-access-secret');
   const { user, mailbox } = createMailboxFixture('pop3.example', 'pop3-user');
   const firstRawMessage = [
     'From: Bob <bob@example.net>',
@@ -271,7 +380,7 @@ test('POP3 clients can retrieve and delete messages on quit', async () => {
     '',
     'Hello through POP3.'
   ].join('\r\n');
-  createInboundMessage(mailbox, {
+  const firstMessage = createInboundMessage(mailbox, {
     sender: 'bob@example.net',
     recipients: ['admin@pop3.example'],
     subject: 'POP3 hello',
@@ -288,7 +397,7 @@ test('POP3 clients can retrieve and delete messages on quit', async () => {
       'Content-Transfer-Encoding: 8bit',
       '',
       'caf'
-    ].join('\r\n'), 'ascii'),
+    ].join('\n'), 'ascii'),
     Buffer.from([0xe9])
   ]);
   createInboundMessage(mailbox, {
@@ -301,7 +410,10 @@ test('POP3 clients can retrieve and delete messages on quit', async () => {
   });
 
   const firstPop3Message = Buffer.from(`${firstRawMessage}\r\n`, 'utf8');
-  const latin1Pop3Message = Buffer.concat([latin1RawMessage, Buffer.from('\r\n')]);
+  const latin1Pop3Message = Buffer.concat([
+    Buffer.from(latin1RawMessage.toString('latin1').replace(/\n/g, '\r\n'), 'latin1'),
+    Buffer.from('\r\n')
+  ]);
   const totalOctets = firstPop3Message.length + latin1Pop3Message.length;
 
   const [server] = startMailboxAccessServers({
@@ -324,9 +436,13 @@ test('POP3 clients can retrieve and delete messages on quit', async () => {
     assert.match(listed, new RegExp(`1 ${firstPop3Message.length}\\r\\n`));
     assert.match(listed, new RegExp(`2 ${latin1Pop3Message.length}\\r\\n`));
     assert.match(await client.command('UIDL 1', /\+OK 1 mh-1/), /\+OK 1 mh-1/);
+    database
+      .prepare('UPDATE inbound_messages SET raw_message_bytes = ? WHERE id = ?')
+      .run(Buffer.from(firstRawMessage.replace('Hello through POP3.', 'Hallo through POP3.'), 'utf8'), firstMessage.id);
     const retrieved = await client.command('RETR 1', /\r\n\.\r\n/);
     assert.match(retrieved, /Subject: POP3 hello/);
-    assert.match(retrieved, /Hello through POP3\./);
+    assert.match(retrieved, /Hallo through POP3\./);
+    assert.doesNotMatch(retrieved, /Hello through POP3\./);
     const latin1Retrieved = await client.commandBytes('RETR 2', /\r\n\.\r\n/);
     assert.deepEqual(latin1Retrieved, Buffer.concat([
       Buffer.from(`+OK ${latin1Pop3Message.length} octets\r\n`),
@@ -343,6 +459,32 @@ test('POP3 clients can retrieve and delete messages on quit', async () => {
   }
 });
 
+test('POP3 AUTH PLAIN requires TLS when insecure authentication is disabled', async () => {
+  const [server] = startMailboxAccessServers({
+    hostname: 'mail.secure-pop3.example',
+    imapEnabled: false,
+    imapListeners: [],
+    pop3Enabled: true,
+    pop3Listeners: [{ port: 0, protocol: 'pop3' }],
+    allowInsecureAuth: false
+  });
+  await waitForListening(server);
+
+  let client;
+  try {
+    client = await connectClient(server.address().port);
+    await client.readUntil(/\+OK .* POP3 ready\r\n/);
+    const credentials = Buffer.from('\u0000user@example.com\u0000password').toString('base64');
+    assert.equal(
+      await client.command(`AUTH PLAIN ${credentials}`, /\+OK|\-ERR/),
+      '-ERR Encryption required for authentication\r\n'
+    );
+  } finally {
+    client?.close();
+    await closeServer(server);
+  }
+});
+
 function createMailboxFixture(domainName, username) {
   const user = createUser({ username, email: `${username}@example.com`, password: 'password123' });
   createDomain(user.id, {

+ 158 - 0
test/mail-auth-rate-limit.test.js

@@ -0,0 +1,158 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import net from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import { AuthenticationRateLimiter } from '../src/auth-rate-limit.js';
+import { createDomain, createInboundMailbox, createUser, initDatabase } from '../src/db.js';
+import { startMailboxAccessServers } from '../src/mail-access.js';
+import { startSubmissionServer } from '../src/submission.js';
+
+test('IMAP, POP3 and SMTP share generic authentication throttling by IP and account', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-auth-rate-limit-')), 'auth-rate-limit-secret');
+  const user = createUser({
+    username: 'auth-rate-limit-user',
+    email: 'auth-rate-limit-user@example.com',
+    password: 'account-password'
+  });
+  createDomain(user.id, {
+    domain: 'auth-rate-limit.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.auth-rate-limit.example',
+    sendingIp: '192.0.2.45',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = createInboundMailbox(user.id, {
+    address: 'admin@auth-rate-limit.example',
+    password: 'correct-password'
+  });
+  const authRateLimiter = new AuthenticationRateLimiter({
+    combinationLimit: 3,
+    accountLimit: 10,
+    ipLimit: 20
+  });
+  const [imapServer, pop3Server] = startMailboxAccessServers({
+    hostname: 'mail.auth-rate-limit.example',
+    imapEnabled: true,
+    imapListeners: [{ port: 0, protocol: 'imap' }],
+    pop3Enabled: true,
+    pop3Listeners: [{ port: 0, protocol: 'pop3' }],
+    allowInsecureAuth: true,
+    authRateLimiter
+  });
+  const [smtpServer] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mail.auth-rate-limit.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true,
+    authRateLimiter
+  });
+  const servers = [imapServer, pop3Server, smtpServer];
+  await Promise.all(servers.map(waitForListening));
+
+  const clients = [];
+  try {
+    const imap = await connectClient(imapServer.address().port);
+    clients.push(imap);
+    await imap.readUntil(/\* OK .* IMAP ready\r\n/);
+    assert.match(
+      await imap.command(`A1 LOGIN "${mailbox.address}" "wrong-imap"`, /A1 NO/),
+      /^A1 NO Authentication failed\r\n$/
+    );
+
+    const pop3 = await connectClient(pop3Server.address().port);
+    clients.push(pop3);
+    await pop3.readUntil(/\+OK .* POP3 ready\r\n/);
+    assert.match(await pop3.command(`USER ${mailbox.address}`, /\+OK|\-ERR/), /^\+OK User accepted\r\n$/);
+    assert.match(await pop3.command('PASS wrong-pop3', /\+OK|\-ERR/), /^\-ERR Authentication failed\r\n$/);
+
+    const smtp = await connectClient(smtpServer.address().port);
+    clients.push(smtp);
+    await smtp.readUntil(/^220 .* ready\r\n/m);
+    await smtp.command('EHLO client.example', /250 SMTPUTF8\r\n/);
+    const wrongAuth = Buffer.from(`\u0000${mailbox.address}\u0000wrong-smtp`).toString('base64');
+    assert.match(
+      await smtp.command(`AUTH PLAIN ${wrongAuth}`, /535 /),
+      /^535 Authentication failed\r\n$/
+    );
+
+    const blocked = await connectClient(smtpServer.address().port);
+    clients.push(blocked);
+    await blocked.readUntil(/^220 .* ready\r\n/m);
+    await blocked.command('EHLO client.example', /250 SMTPUTF8\r\n/);
+    const correctAuth = Buffer.from(`\u0000${mailbox.address}\u0000correct-password`).toString('base64');
+    assert.match(
+      await blocked.command(`AUTH PLAIN ${correctAuth}`, /535 /),
+      /^535 Authentication failed\r\n$/
+    );
+  } finally {
+    for (const client of clients) client.close();
+    await Promise.all(servers.map(closeServer));
+  }
+});
+
+function connectClient(port) {
+  return new Promise((resolve, reject) => {
+    const socket = net.createConnection({ host: '127.0.0.1', port });
+    socket.setTimeout(5_000);
+    let buffer = '';
+    const waiters = [];
+
+    socket.on('data', (chunk) => {
+      buffer += chunk.toString('utf8');
+      for (const waiter of [...waiters]) {
+        if (!waiter.pattern.test(buffer)) continue;
+        waiters.splice(waiters.indexOf(waiter), 1);
+        const output = buffer;
+        buffer = '';
+        clearTimeout(waiter.timer);
+        waiter.resolve(output);
+      }
+    });
+    socket.once('connect', () => resolve({
+      command(command, pattern) {
+        socket.write(`${command}\r\n`);
+        return this.readUntil(pattern);
+      },
+      readUntil(pattern) {
+        if (pattern.test(buffer)) {
+          const output = buffer;
+          buffer = '';
+          return Promise.resolve(output);
+        }
+        return new Promise((waitResolve, waitReject) => {
+          const waiter = { pattern, resolve: waitResolve, timer: null };
+          waiter.timer = setTimeout(() => {
+            waiters.splice(waiters.indexOf(waiter), 1);
+            waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`));
+          }, 5_000);
+          waiters.push(waiter);
+        });
+      },
+      close() {
+        socket.destroy();
+      }
+    }));
+    socket.once('error', reject);
+    socket.once('timeout', () => reject(new Error('Mail protocol client timed out')));
+  });
+}
+
+function waitForListening(server) {
+  if (server.listening) return Promise.resolve();
+  return new Promise((resolve) => server.once('listening', resolve));
+}
+
+function closeServer(server) {
+  return new Promise((resolve, reject) => {
+    server.close((error) => error ? reject(error) : resolve());
+  });
+}

+ 38 - 0
test/password-hash.test.js

@@ -0,0 +1,38 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import {
+  hashPassword,
+  isLegacyPasswordHash,
+  verifyPassword,
+  verifyScryptPassword,
+  verifyVestaPassword
+} from '../src/password-hash.js';
+
+test('hashes and verifies current scrypt passwords', () => {
+  const stored = hashPassword('correct horse battery staple');
+
+  assert.match(stored, /^scrypt\$[0-9a-f]{32}\$[0-9a-f]{128}$/);
+  assert.equal(verifyScryptPassword('correct horse battery staple', stored), true);
+  assert.equal(verifyPassword('correct horse battery staple', stored), true);
+  assert.equal(verifyPassword('wrong password', stored), false);
+});
+
+test('verifies Vesta MD5-CRYPT passwords with known vectors', () => {
+  const passwordVector = '$1$hfT7jp2q$G3yf0NUx7mUkX.LIFWQxN.';
+  const unicodeVector = '$1$salt1234$VwTk0ScCcREDNl.8aCJCc0';
+
+  assert.equal(verifyVestaPassword('password', `{MD5}${passwordVector}`), true);
+  assert.equal(verifyVestaPassword('password', `{MD5-CRYPT}${passwordVector}`), true);
+  assert.equal(verifyVestaPassword('pässwörd', unicodeVector), true);
+  assert.equal(verifyPassword('password', `{MD5}${passwordVector}`), true);
+  assert.equal(verifyVestaPassword('wrong password', `{MD5}${passwordVector}`), false);
+});
+
+test('detects only supported legacy password hashes', () => {
+  assert.equal(isLegacyPasswordHash('{MD5}$1$12345678$xek.CpjQUVgdf/P2N9KQf/'), true);
+  assert.equal(isLegacyPasswordHash('{MD5-CRYPT}$1$12345678$xek.CpjQUVgdf/P2N9KQf/'), true);
+  assert.equal(isLegacyPasswordHash('$1$12345678$xek.CpjQUVgdf/P2N9KQf/'), true);
+  assert.equal(isLegacyPasswordHash('{SHA256-CRYPT}$5$salt$hash'), false);
+  assert.equal(isLegacyPasswordHash('{MD5}$1$toolongsalt$invalid'), false);
+  assert.equal(verifyPassword('password', 'malformed'), false);
+});

+ 49 - 4
test/submission-inbound.test.js

@@ -187,9 +187,22 @@ test('SMTP authenticates with a mailbox account address and password', async ()
     dmarcPolicy: 'none',
     dmarcRua: ''
   });
+  createDomain(user.id, {
+    domain: 'other-authmail.example',
+    selector: 'mh',
+    verificationToken: 'verify-other',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.other-authmail.example',
+    sendingIp: '192.0.2.17',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
   createInboundMailbox(user.id, {
     address: 'admin@authmail.example',
-    password: 'mailbox-pass-123'
+    password: 'mailbox-pass-123',
+    aliases: ['sales']
   });
   const [server] = startSubmissionServer({
     enabled: true,
@@ -202,11 +215,43 @@ test('SMTP authenticates with a mailbox account address and password', async ()
 
   try {
     const auth = Buffer.from('\u0000admin@authmail.example\u0000mailbox-pass-123').toString('base64');
-    const transcript = await smtpTranscript(server.address().port, [
+    const ownSender = await smtpTranscript(server.address().port, [
+      'EHLO sender.example.net',
+      `AUTH PLAIN ${auth}`,
+      'MAIL FROM:<admin@authmail.example>'
+    ]);
+    assert.match(ownSender.at(-1), /^250 /);
+
+    const aliasSender = await smtpTranscript(server.address().port, [
+      'EHLO sender.example.net',
+      `AUTH PLAIN ${auth}`,
+      'MAIL FROM:<sales@authmail.example>'
+    ]);
+    assert.match(aliasSender.at(-1), /^250 /);
+
+    const crossMailboxSender = await smtpTranscript(server.address().port, [
+      'EHLO sender.example.net',
+      `AUTH PLAIN ${auth}`,
+      'MAIL FROM:<other@other-authmail.example>'
+    ]);
+    assert.match(crossMailboxSender.at(-1), /^553 /);
+
+    const crossMailboxHeader = await smtpTranscript(server.address().port, [
       'EHLO sender.example.net',
-      `AUTH PLAIN ${auth}`
+      `AUTH PLAIN ${auth}`,
+      'MAIL FROM:<admin@authmail.example>',
+      'RCPT TO:<recipient@example.net>',
+      'DATA',
+      [
+        'From: Other <other@other-authmail.example>',
+        'To: recipient@example.net',
+        'Subject: blocked mailbox impersonation',
+        '',
+        'This message must not be relayed.',
+        '.'
+      ].join('\r\n')
     ]);
-    assert.match(transcript.at(-1), /^235 /);
+    assert.match(crossMailboxHeader.at(-1), /^553 /);
   } finally {
     await closeServer(server);
   }

+ 353 - 0
test/vesta-import-cli.test.js

@@ -0,0 +1,353 @@
+import assert from 'node:assert/strict';
+import {
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  utimesSync,
+  writeFileSync
+} from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { DatabaseSync } from 'node:sqlite';
+import { test } from 'node:test';
+
+import {
+  createAtomicCheckpointWriter,
+  parseVestaImportArguments,
+  preflightVestaImport,
+  runVestaMaildirImport
+} from '../scripts/import-vesta-maildir.js';
+import {
+  getDomainByName,
+  initDatabase,
+  listInboundMailboxes,
+  listInboundMessages,
+  seedAdminUser
+} from '../src/db.js';
+
+const legacyHash = '{MD5}$1$hfT7jp2q$G3yf0NUx7mUkX.LIFWQxN.';
+
+test('CLI arguments support production import paths and dry-run', () => {
+  const parsed = parseVestaImportArguments([
+    '--snapshot', 'snapshot',
+    '--data-dir=data',
+    '--user', 'admin',
+    '--source', 'VESTA:Primary',
+    '--checkpoint', 'state/checkpoint.json',
+    '--dry-run'
+  ], { cwd: '/tmp/mailhub-cli', env: {} });
+
+  assert.equal(parsed.snapshot, '/tmp/mailhub-cli/snapshot');
+  assert.equal(parsed.dataDir, '/tmp/mailhub-cli/data');
+  assert.equal(parsed.user, 'admin');
+  assert.equal(parsed.source, 'vesta:primary');
+  assert.equal(parsed.checkpoint, '/tmp/mailhub-cli/state/checkpoint.json');
+  assert.equal(parsed.dryRun, true);
+});
+
+test('dry-run checks all 35 hashes without business writes, then real import resumes safely', async () => {
+  const fixture = createSnapshotFixture(35);
+  const dataDir = mkdtempSync(path.join(os.tmpdir(), 'mailhub-vesta-cli-db-'));
+  initDatabase(dataDir, 'cli-test-secret');
+  const user = seedAdminUser({
+    username: 'admin',
+    email: 'admin@example.test',
+    password: 'admin-password'
+  });
+  const checkpoint = path.join(dataDir, 'import.checkpoint.json');
+  const output = captureOutput();
+  const before = businessCounts(dataDir);
+  const common = {
+    snapshot: fixture.root,
+    dataDir,
+    user: 'admin',
+    source: 'vesta:test',
+    checkpoint
+  };
+
+  const dryRun = await runVestaMaildirImport({ ...common, dryRun: true }, {
+    env: { MAIL_HOSTNAME: 'smtp.target.test', SENDING_IP: '192.0.2.44' },
+    output
+  });
+  assert.deepEqual(businessCounts(dataDir), before);
+  assert.equal(dryRun.preflight.hashes, 35);
+  assert.equal(dryRun.preflight.supportedHashes, 35);
+  assert.equal(dryRun.preflight.unsupportedHashes, 0);
+  assert.equal(dryRun.summary.plannedMessages, 1);
+  assert.equal(dryRun.summary.importedMessages, 0);
+  assert.equal(existsSync(checkpoint), false);
+  assert.match(output.text(), /preflight .*hashes=35 .*supported_hashes=35/);
+  assert.match(output.text(), /complete dry_run=1/);
+  assertNoSensitiveOutput(output.text());
+
+  output.clear();
+  const imported = await runVestaMaildirImport(common, {
+    env: {
+      SESSION_SECRET: 'cli-test-secret',
+      MAIL_HOSTNAME: 'smtp.target.test',
+      SENDING_IP: '192.0.2.44',
+      DEFAULT_SPF_MECHANISMS: 'ip4:192.0.2.44',
+      DMARC_POLICY: 'quarantine',
+      DMARC_RUA: 'mailto:dmarc@target.test'
+    },
+    output
+  });
+  assert.equal(imported.summary.importedMessages, 1);
+  assert.equal(listInboundMailboxes(user.id).length, 35);
+  assert.equal(listInboundMessages(user.id, { folder: null }).length, 1);
+  const domain = getDomainByName('import.example', { userId: user.id });
+  assert.equal(domain.catchAllAddress, 'acct00@import.example');
+  assert.equal(domain.senderHost, 'smtp.target.test');
+  assert.equal(domain.sendingIp, '192.0.2.44');
+  assert.equal(domain.spfExtra, 'ip4:192.0.2.44');
+  assert.equal(domain.dmarcPolicy, 'quarantine');
+  assert.equal(domain.dmarcRua, 'mailto:dmarc@target.test');
+  assertNoSensitiveOutput(output.text());
+
+  const checkpointValue = JSON.parse(readFileSync(checkpoint, 'utf8'));
+  assert.equal(checkpointValue.complete, true);
+  assert.equal(checkpointValue.processed, 1);
+  assert.equal(typeof checkpointValue.lastSourceKey, 'string');
+  assert.equal('warnings' in checkpointValue, false);
+  assert.equal(checkpointValue.warningCount, 34);
+  assertNoSensitiveOutput(JSON.stringify(checkpointValue));
+
+  output.clear();
+  const resumed = await runVestaMaildirImport(common, {
+    env: { SESSION_SECRET: 'cli-test-secret' },
+    output
+  });
+  assert.equal(resumed.summary.importedMessages, 0);
+  assert.equal(resumed.summary.skippedMessages, 1);
+  assert.equal(resumed.summary.resumeSkippedMessages, 0);
+  assert.equal(listInboundMessages(user.id, { folder: null }).length, 1);
+  assertNoSensitiveOutput(output.text());
+
+  writeFileSync(
+    path.join(fixture.root, 'usr/local/vesta/data/users/admin/mail.conf'),
+    "DOMAIN='import.example' CATCHALL='acct01' SUSPENDED='no'\n"
+  );
+  await assert.rejects(
+    runVestaMaildirImport(common, {
+      env: { SESSION_SECRET: 'cli-test-secret' },
+      output
+    }),
+    (error) => error?.category === 'checkpoint'
+  );
+  assert.equal(getDomainByName('import.example', { userId: user.id }).catchAllAddress, 'acct00@import.example');
+});
+
+test('preflight counts unsupported hashes and cross-user conflicts without exposing resource values', () => {
+  const result = preflightVestaImport({
+    domains: [{ domain: 'conflict.example' }],
+    mailboxes: [{ address: 'private@conflict.example', passwordHash: '{SHA512-CRYPT}redacted' }],
+    warnings: ['sensitive warning']
+  }, {
+    user: { id: 7 },
+    domains: [{ domain: 'conflict.example', userId: 8 }],
+    mailboxes: [{ address: 'private@conflict.example', userId: 8 }]
+  });
+
+  assert.equal(result.conflicts, 2);
+  assert.equal(result.hashes, 1);
+  assert.equal(result.unsupportedHashes, 1);
+  assert.equal(result.warningCount, 1);
+  assert.equal('warnings' in result, false);
+});
+
+test('preflight blocks missing hashes and invalid defaults for new domains', () => {
+  const result = preflightVestaImport({
+    domains: [{ domain: 'new.example' }],
+    mailboxes: [{ address: 'missing@new.example', passwordHash: '' }],
+    warnings: []
+  }, {
+    user: { id: 7 },
+    domains: [],
+    mailboxes: []
+  }, {
+    defaults: { senderHost: 'invalid host', sendingIp: '' }
+  });
+
+  assert.equal(result.newDomains, 1);
+  assert.equal(result.missingHashes, 1);
+  assert.equal(result.invalidDefaults, 1);
+});
+
+test('preflight blocks a soft-deleted mailbox that the import cannot reuse', () => {
+  const result = preflightVestaImport({
+    domains: [{ domain: 'existing.example' }],
+    mailboxes: [{ address: 'archived@existing.example', passwordHash: legacyHash }],
+    warnings: []
+  }, {
+    user: { id: 7 },
+    domains: [{ domain: 'existing.example', userId: 7 }],
+    mailboxes: [{
+      address: 'archived@existing.example',
+      userId: 7,
+      deletedAt: '2026-07-15T00:00:00.000Z'
+    }]
+  });
+
+  assert.equal(result.conflicts, 1);
+});
+
+test('preflight only allows an existing same-owner mailbox during checkpoint resume', () => {
+  const snapshot = {
+    domains: [{ domain: 'existing.example' }],
+    mailboxes: [{ address: 'owner@existing.example', passwordHash: legacyHash }],
+    warnings: []
+  };
+  const inventory = {
+    user: { id: 7 },
+    domains: [{ domain: 'existing.example', userId: 7 }],
+    mailboxes: [{ address: 'owner@existing.example', userId: 7, deletedAt: null }]
+  };
+
+  const firstRun = preflightVestaImport(snapshot, inventory);
+  const resume = preflightVestaImport(snapshot, inventory, { allowExistingMailboxes: true });
+
+  assert.equal(firstRun.existingMailboxes, 1);
+  assert.equal(firstRun.conflicts, 1);
+  assert.equal(resume.existingMailboxes, 1);
+  assert.equal(resume.conflicts, 0);
+});
+
+test('real import writes an incomplete checkpoint before entering the importer', async () => {
+  const directory = mkdtempSync(path.join(os.tmpdir(), 'mailhub-vesta-initial-checkpoint-'));
+  const checkpoint = path.join(directory, 'checkpoint.json');
+  const user = { id: 7, username: 'admin', email: 'admin@example.test', status: 'active' };
+  const snapshot = {
+    domains: [{ domain: 'new.example' }],
+    mailboxes: [{ address: 'owner@new.example', passwordHash: legacyHash }],
+    warnings: ['count only']
+  };
+
+  await assert.rejects(
+    runVestaMaildirImport({
+      snapshot: path.join(directory, 'snapshot'),
+      dataDir: directory,
+      user: 'admin',
+      source: 'vesta:checkpoint',
+      checkpoint
+    }, {
+      env: { MAIL_HOSTNAME: 'smtp.target.test', SENDING_IP: '192.0.2.44' },
+      inspectTarget: () => ({ user, domains: [], mailboxes: [] }),
+      readSnapshot: async () => snapshot,
+      dbApi: {
+        initDatabase() {},
+        getUserByLogin: () => user
+      },
+      importSnapshot: async () => {
+        const initial = JSON.parse(readFileSync(checkpoint, 'utf8'));
+        assert.equal(initial.complete, false);
+        assert.equal(initial.lastSourceKey, '');
+        assert.equal(initial.processed, 0);
+        assert.equal(initial.warningCount, 1);
+        throw new Error('simulated mailbox-stage crash');
+      },
+      output: captureOutput()
+    }),
+    /simulated mailbox-stage crash/
+  );
+
+  assert.equal(JSON.parse(readFileSync(checkpoint, 'utf8')).complete, false);
+});
+
+test('checkpoint writer atomically flushes every 100 messages or one second', async () => {
+  const directory = mkdtempSync(path.join(os.tmpdir(), 'mailhub-vesta-checkpoint-'));
+  const filePath = path.join(directory, 'checkpoint.json');
+  let timestamp = 0;
+  const writer = createAtomicCheckpointWriter({
+    filePath,
+    identity: { version: 1, source: 'vesta:test', targetUserId: 1, snapshotId: 'snapshot-id' },
+    clock: () => timestamp
+  });
+
+  for (let index = 1; index < 100; index += 1) {
+    assert.equal(await writer.record(`source-${index}`, { processed: index, bytes: index }), false);
+  }
+  assert.equal(existsSync(filePath), false);
+  assert.equal(await writer.record('source-100', { processed: 100, bytes: 100 }), true);
+  assert.equal(JSON.parse(readFileSync(filePath, 'utf8')).processed, 100);
+
+  timestamp = 1000;
+  assert.equal(await writer.record('source-101', { processed: 101, bytes: 101 }), true);
+  const checkpoint = JSON.parse(readFileSync(filePath, 'utf8'));
+  assert.equal(checkpoint.processed, 101);
+  assert.equal(checkpoint.lastSourceKey, 'source-101');
+  assert.equal(checkpoint.complete, false);
+});
+
+function createSnapshotFixture(accountCount) {
+  const root = mkdtempSync(path.join(os.tmpdir(), 'mailhub-vesta-cli-snapshot-'));
+  const userData = path.join(root, 'usr/local/vesta/data/users/admin');
+  const accountConfig = path.join(userData, 'mail');
+  const mailRoot = path.join(root, 'home/admin/mail/import.example');
+  const firstMailbox = path.join(mailRoot, 'acct00');
+  for (const directory of [
+    accountConfig,
+    path.join(firstMailbox, 'cur'),
+    path.join(firstMailbox, 'new'),
+    path.join(firstMailbox, 'tmp')
+  ]) mkdirSync(directory, { recursive: true });
+  writeFileSync(
+    path.join(userData, 'mail.conf'),
+    "DOMAIN='import.example' CATCHALL='acct00' SUSPENDED='no'\n"
+  );
+  const accounts = [];
+  for (let index = 0; index < accountCount; index += 1) {
+    const account = `acct${String(index).padStart(2, '0')}`;
+    accounts.push(
+      `ACCOUNT='${account}' MD5='${legacyHash}' QUOTA='unlimited' SUSPENDED='no' DATE='2024-01-02' TIME='03:04:05'`
+    );
+  }
+  writeFileSync(path.join(accountConfig, 'import.example.conf'), `${accounts.join('\n')}\n`);
+  const messagePath = path.join(firstMailbox, 'cur', '1700000000.M1P1.host:2,S');
+  writeFileSync(messagePath, [
+    'From: sender@example.net',
+    'To: acct00@import.example',
+    'Subject: Private migration subject',
+    'Message-ID: <private-import@example.net>',
+    '',
+    'Private migration body'
+  ].join('\r\n'));
+  const receivedAt = new Date('2024-01-02T03:04:05.000Z');
+  utimesSync(messagePath, receivedAt, receivedAt);
+  return { root };
+}
+
+function businessCounts(dataDir) {
+  const database = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'), { readOnly: true });
+  try {
+    return {
+      domains: Number(database.prepare('SELECT COUNT(*) AS count FROM domains').get().count),
+      mailboxes: Number(database.prepare('SELECT COUNT(*) AS count FROM inbound_mailboxes').get().count),
+      messages: Number(database.prepare('SELECT COUNT(*) AS count FROM inbound_messages').get().count)
+    };
+  } finally {
+    database.close();
+  }
+}
+
+function captureOutput() {
+  let value = '';
+  return {
+    write(chunk) {
+      value += String(chunk);
+      return true;
+    },
+    text() {
+      return value;
+    },
+    clear() {
+      value = '';
+    }
+  };
+}
+
+function assertNoSensitiveOutput(value) {
+  assert.doesNotMatch(value, /acct00@import\.example/i);
+  assert.doesNotMatch(value, /\$1\$hfT7jp2q/i);
+  assert.doesNotMatch(value, /Private migration (?:subject|body)/i);
+}

+ 121 - 0
test/vesta-import-db.test.js

@@ -0,0 +1,121 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+import {
+  createDomain,
+  createImportedInboundMessage,
+  getInboundMailbox,
+  getInboundMailboxProtocolMessage,
+  getInboundMessage,
+  hasImportedInboundMessage,
+  initDatabase,
+  listInboundMailboxProtocolMessages,
+  searchInboundMessages,
+  seedAdminUser,
+  upsertImportedInboundMailbox,
+  verifyInboundMailboxCredential
+} from '../src/db.js';
+import { createDkimKeyPair } from '../src/dkim.js';
+import { isLegacyPasswordHash } from '../src/password-hash.js';
+
+function tempDataDir() {
+  return mkdtempSync(path.join(os.tmpdir(), 'mailhub-vesta-import-'));
+}
+
+function createTestDomain(userId, domainName) {
+  const keys = createDkimKeyPair();
+  return createDomain(userId, {
+    domain: domainName,
+    selector: 'mhimport',
+    verificationToken: 'import-verification-token',
+    dkimPublic: keys.publicKey,
+    dkimPrivate: keys.privateKey,
+    senderHost: `mail.${domainName}`,
+    sendingIp: '192.0.2.10',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+}
+
+test('imports Vesta mailbox hashes without plaintext and upgrades after successful login', () => {
+  initDatabase(tempDataDir(), 'import-test-secret');
+  const user = seedAdminUser({ username: 'admin', email: 'admin@example.test', password: 'admin-password' });
+  createTestDomain(user.id, 'legacy.example');
+  const legacyHash = '{MD5}$1$hfT7jp2q$G3yf0NUx7mUkX.LIFWQxN.';
+
+  const mailbox = upsertImportedInboundMailbox(user.id, {
+    address: 'inbox@legacy.example',
+    passwordHash: legacyHash,
+    quotaMb: null,
+    status: 'active'
+  });
+  const before = getInboundMailbox(mailbox.id, user.id, { includeHash: true, includeSecret: true });
+  assert.equal(before.passwordHash, legacyHash);
+  assert.equal(before.passwordSecret, '');
+  assert.equal(before.passwordRecoverable, false);
+  assert.equal(verifyInboundMailboxCredential(mailbox.address, 'wrong password'), null);
+
+  assert.ok(verifyInboundMailboxCredential(mailbox.address, 'password'));
+  const upgraded = getInboundMailbox(mailbox.id, user.id, { includeHash: true, includeSecret: true });
+  assert.match(upgraded.passwordHash, /^scrypt\$/);
+  assert.equal(isLegacyPasswordHash(upgraded.passwordHash), false);
+  assert.equal(upgraded.passwordSecret, '');
+
+  upsertImportedInboundMailbox(user.id, { address: mailbox.address, passwordHash: legacyHash });
+  const afterResume = getInboundMailbox(mailbox.id, user.id, { includeHash: true });
+  assert.equal(afterResume.passwordHash, upgraded.passwordHash);
+});
+
+test('imports raw Maildir bytes, flags, timestamps and skips the same source key', () => {
+  initDatabase(tempDataDir(), 'import-test-secret');
+  const user = seedAdminUser({ username: 'admin', email: 'admin@example.test', password: 'admin-password' });
+  createTestDomain(user.id, 'mail.example');
+  const mailbox = upsertImportedInboundMailbox(user.id, {
+    address: 'archive@mail.example',
+    passwordHash: '{MD5}$1$hfT7jp2q$G3yf0NUx7mUkX.LIFWQxN.'
+  });
+  const rawMessageBytes = Buffer.from('From: sender@example.test\r\nSubject: imported\r\n\r\nbody', 'utf8');
+  const payload = {
+    importSource: 'vesta:ali.ss5.xyz',
+    sourceKey: 'maildir-v1-source-key',
+    folder: 'Projects/2026',
+    sender: 'sender@example.test',
+    recipients: [mailbox.address],
+    subject: 'imported',
+    messageId: '',
+    rawMessageBytes,
+    textBody: 'body',
+    flags: ['\\Seen', '\\Flagged'],
+    keywords: ['$Forwarded'],
+    receivedAt: '2026-07-01T12:34:56.000Z'
+  };
+
+  const first = createImportedInboundMessage(mailbox, payload);
+  const second = createImportedInboundMessage(mailbox, payload);
+  assert.equal(first.created, true);
+  assert.equal(second.created, false);
+  assert.equal(second.message.id, first.message.id);
+  assert.equal(hasImportedInboundMessage(payload.importSource, payload.sourceKey), true);
+
+  const message = getInboundMessage(user.id, first.message.id);
+  assert.equal(message.rawMessage, rawMessageBytes.toString('utf8'));
+  assert.equal(message.read, true);
+  assert.deepEqual(message.flags, ['\\Seen', '\\Flagged']);
+  assert.deepEqual(message.keywords, ['$Forwarded']);
+  assert.equal(message.folder, 'Projects/2026');
+  assert.equal(message.receivedAt, payload.receivedAt);
+
+  const [summary] = listInboundMailboxProtocolMessages(mailbox, { folder: payload.folder });
+  assert.equal(summary.rawMessageSize, rawMessageBytes.length);
+  assert.equal(summary.pop3MessageSize, rawMessageBytes.length + 2);
+  assert.equal(Object.hasOwn(summary, 'rawMessage'), false);
+  assert.equal(Object.hasOwn(summary, 'rawMessageBytes'), false);
+  const hydrated = getInboundMailboxProtocolMessage(mailbox, summary.id, { folder: payload.folder });
+  assert.ok(hydrated.rawMessageBytes.equals(rawMessageBytes));
+  const searchResult = searchInboundMessages(user.id, { folder: payload.folder });
+  assert.equal(searchResult.messages[0].rawMessageSize, rawMessageBytes.length);
+  assert.equal(Object.hasOwn(searchResult.messages[0], 'rawMessageBytes'), false);
+});

+ 195 - 0
test/vesta-legacy-auth.test.js

@@ -0,0 +1,195 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import net from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  createDomain,
+  createUser,
+  getInboundMailbox,
+  initDatabase,
+  upsertImportedInboundMailbox
+} from '../src/db.js';
+import { startMailboxAccessServers } from '../src/mail-access.js';
+import { startSubmissionServer } from '../src/submission.js';
+
+const legacyPassword = 'Vesta-Legacy-Pass!2026';
+const legacyPasswordHash = '{MD5}$1$MhVesta1$tmBXF7EjU15TTV.VWRFfl1';
+
+test('Vesta legacy mailbox password authenticates over IMAP, POP3 and SMTP then upgrades without plaintext', async () => {
+  const database = initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-vesta-auth-')), 'vesta-auth-secret');
+  const user = createUser({
+    username: 'vesta-auth-user',
+    email: 'vesta-auth-user@example.test',
+    password: 'account-password'
+  });
+  createDomain(user.id, {
+    domain: 'vesta-auth.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.vesta-auth.example',
+    sendingIp: '192.0.2.40',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = upsertImportedInboundMailbox(user.id, {
+    address: 'legacy@vesta-auth.example',
+    passwordHash: legacyPasswordHash
+  });
+
+  const imported = database
+    .prepare('SELECT password_hash, password_secret FROM inbound_mailboxes WHERE id = ?')
+    .get(mailbox.id);
+  assert.equal(imported.password_hash, legacyPasswordHash);
+  assert.equal(imported.password_secret, '');
+
+  const publicMailbox = getInboundMailbox(mailbox.id, user.id);
+  assert.equal(Object.hasOwn(publicMailbox, 'password'), false);
+  assert.equal(Object.hasOwn(publicMailbox, 'passwordHash'), false);
+  assert.equal(Object.hasOwn(publicMailbox, 'passwordSecret'), false);
+  assert.equal(JSON.stringify(publicMailbox).includes(legacyPassword), false);
+
+  const [imapServer, pop3Server] = startMailboxAccessServers({
+    hostname: 'mail.vesta-auth.example',
+    imapEnabled: true,
+    imapListeners: [{ port: 0, protocol: 'imap' }],
+    pop3Enabled: true,
+    pop3Listeners: [{ port: 0, protocol: 'pop3' }],
+    allowInsecureAuth: true
+  });
+  const [smtpServer] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mail.vesta-auth.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true
+  });
+  const servers = [imapServer, pop3Server, smtpServer];
+  await Promise.all(servers.map(waitForListening));
+
+  let imapClient;
+  let pop3Client;
+  let smtpClient;
+  try {
+    imapClient = await connectClient(imapServer.address().port);
+    await imapClient.readUntil(/\* OK .* IMAP ready\r\n/);
+    const imapLogin = await imapClient.command(
+      `A1 LOGIN "${mailbox.address}" "${legacyPassword}"`,
+      /A1 (?:OK|NO)/
+    );
+    assert.match(imapLogin, /A1 OK LOGIN completed/);
+
+    const upgraded = database
+      .prepare('SELECT password_hash, password_secret FROM inbound_mailboxes WHERE id = ?')
+      .get(mailbox.id);
+    assert.match(upgraded.password_hash, /^scrypt\$/);
+    assert.notEqual(upgraded.password_hash, legacyPasswordHash);
+    assert.equal(upgraded.password_secret, '');
+    assert.equal(JSON.stringify(upgraded).includes(legacyPassword), false);
+
+    await imapClient.command('A2 LOGOUT', /A2 OK/);
+    imapClient.close();
+    imapClient = null;
+
+    pop3Client = await connectClient(pop3Server.address().port);
+    await pop3Client.readUntil(/\+OK .* POP3 ready\r\n/);
+    assert.match(
+      await pop3Client.command(`USER ${mailbox.address}`, /\+OK|\-ERR/),
+      /\+OK User accepted/
+    );
+    assert.match(
+      await pop3Client.command(`PASS ${legacyPassword}`, /\+OK|\-ERR/),
+      /\+OK Mailbox locked and ready/
+    );
+    await pop3Client.command('QUIT', /\+OK Bye/);
+    pop3Client.close();
+    pop3Client = null;
+
+    smtpClient = await connectClient(smtpServer.address().port);
+    await smtpClient.readUntil(/^220 .* ready\r\n/m);
+    const ehlo = await smtpClient.command('EHLO client.example', /250 SMTPUTF8\r\n/);
+    assert.match(ehlo, /250-AUTH PLAIN LOGIN/);
+    const auth = Buffer.from(`\u0000${mailbox.address}\u0000${legacyPassword}`).toString('base64');
+    assert.match(
+      await smtpClient.command(`AUTH PLAIN ${auth}`, /235 |535 /),
+      /235 Authentication successful/
+    );
+    await smtpClient.command('QUIT', /221 Bye/);
+    smtpClient.close();
+    smtpClient = null;
+
+    const persisted = getInboundMailbox(mailbox.id, user.id, { includeHash: true, includeSecret: true });
+    assert.match(persisted.passwordHash, /^scrypt\$/);
+    assert.equal(persisted.passwordSecret, '');
+    assert.equal(persisted.passwordRecoverable, false);
+    assert.equal(Object.hasOwn(getInboundMailbox(mailbox.id, user.id), 'passwordHash'), false);
+  } finally {
+    imapClient?.close();
+    pop3Client?.close();
+    smtpClient?.close();
+    await Promise.all(servers.map(closeServer));
+  }
+});
+
+function connectClient(port) {
+  return new Promise((resolve, reject) => {
+    const socket = net.createConnection({ host: '127.0.0.1', port });
+    socket.setTimeout(5000);
+    let buffer = '';
+    const waiters = [];
+
+    socket.on('data', (chunk) => {
+      buffer += chunk.toString('utf8');
+      for (const waiter of [...waiters]) {
+        if (!waiter.pattern.test(buffer)) continue;
+        waiters.splice(waiters.indexOf(waiter), 1);
+        const output = buffer;
+        buffer = '';
+        clearTimeout(waiter.timer);
+        waiter.resolve(output);
+      }
+    });
+    socket.once('connect', () => resolve({
+      command(command, pattern) {
+        socket.write(`${command}\r\n`);
+        return this.readUntil(pattern);
+      },
+      readUntil(pattern) {
+        if (pattern.test(buffer)) {
+          const output = buffer;
+          buffer = '';
+          return Promise.resolve(output);
+        }
+        return new Promise((waitResolve, waitReject) => {
+          const waiter = { pattern, resolve: waitResolve, timer: null };
+          waiter.timer = setTimeout(() => {
+            waiters.splice(waiters.indexOf(waiter), 1);
+            waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`));
+          }, 5000);
+          waiters.push(waiter);
+        });
+      },
+      close() {
+        socket.destroy();
+      }
+    }));
+    socket.once('error', reject);
+    socket.once('timeout', () => reject(new Error('Mail protocol client timed out')));
+  });
+}
+
+function waitForListening(server) {
+  if (server.listening) return Promise.resolve();
+  return new Promise((resolve) => server.once('listening', resolve));
+}
+
+function closeServer(server) {
+  return new Promise((resolve, reject) => {
+    server.close((error) => error ? reject(error) : resolve());
+  });
+}

+ 277 - 0
test/vesta-maildir-import.test.js

@@ -0,0 +1,277 @@
+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 };
+}