import-vesta-maildir.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729
  1. #!/usr/bin/env node
  2. import crypto from 'node:crypto';
  3. import { existsSync, readFileSync } from 'node:fs';
  4. import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
  5. import path from 'node:path';
  6. import { pathToFileURL } from 'node:url';
  7. import { DatabaseSync } from 'node:sqlite';
  8. import {
  9. createDomain,
  10. createInboundFolder,
  11. createImportedInboundMessage,
  12. getDomainByName,
  13. getUser,
  14. getUserByLogin,
  15. hasImportedInboundMessage,
  16. initDatabase,
  17. updateDomain,
  18. upsertImportedInboundMailbox
  19. } from '../src/db.js';
  20. import { createDkimKeyPair } from '../src/dkim.js';
  21. import { isLegacyPasswordHash } from '../src/password-hash.js';
  22. import {
  23. importVestaSnapshot,
  24. readVestaSnapshotMetadata
  25. } from '../src/vesta-maildir-import.js';
  26. const checkpointVersion = 1;
  27. const checkpointMessageInterval = 100;
  28. const checkpointTimeIntervalMs = 1000;
  29. const defaultDbApi = {
  30. createDomain,
  31. createInboundFolder,
  32. createImportedInboundMessage,
  33. getDomainByName,
  34. getUser,
  35. getUserByLogin,
  36. hasImportedInboundMessage,
  37. initDatabase,
  38. updateDomain,
  39. upsertImportedInboundMailbox
  40. };
  41. export class VestaImportCliError extends Error {
  42. constructor(category, message) {
  43. super(message);
  44. this.name = 'VestaImportCliError';
  45. this.category = category;
  46. }
  47. }
  48. export function parseVestaImportArguments(argv, {
  49. env = process.env,
  50. cwd = process.cwd()
  51. } = {}) {
  52. const values = {};
  53. let help = false;
  54. for (let index = 0; index < argv.length; index += 1) {
  55. const argument = String(argv[index] || '');
  56. if (argument === '--help' || argument === '-h') {
  57. help = true;
  58. continue;
  59. }
  60. if (argument === '--dry-run') {
  61. values.dryRun = true;
  62. continue;
  63. }
  64. const inline = argument.match(/^--([a-z-]+)=(.*)$/);
  65. const name = inline?.[1] || argument.match(/^--([a-z-]+)$/)?.[1] || '';
  66. if (!['snapshot', 'data-dir', 'user', 'source', 'checkpoint'].includes(name)) {
  67. throw new VestaImportCliError('usage', '导入参数不正确。');
  68. }
  69. const value = inline ? inline[2] : argv[++index];
  70. if (value === undefined || String(value).trim() === '') {
  71. throw new VestaImportCliError('usage', '导入参数缺少值。');
  72. }
  73. values[toCamelCase(name)] = String(value).trim();
  74. }
  75. if (help) return { help: true };
  76. const snapshot = values.snapshot ? path.resolve(cwd, values.snapshot) : '';
  77. const dataDir = path.resolve(cwd, values.dataDir || env.DATA_DIR || 'data');
  78. const user = String(values.user || '').trim();
  79. const source = normalizeImportSource(values.source);
  80. const checkpoint = path.resolve(
  81. cwd,
  82. values.checkpoint || path.join(dataDir, 'vesta-maildir-import.checkpoint.json')
  83. );
  84. if (!snapshot || !user || !source) {
  85. throw new VestaImportCliError('usage', '必须指定快照、目标用户和导入来源。');
  86. }
  87. return {
  88. help: false,
  89. snapshot,
  90. dataDir,
  91. user,
  92. source,
  93. checkpoint,
  94. dryRun: Boolean(values.dryRun)
  95. };
  96. }
  97. export function loadMailHubEnvironment({
  98. env = process.env,
  99. cwd = process.cwd()
  100. } = {}) {
  101. const file = path.join(cwd, '.env');
  102. if (!existsSync(file)) return env;
  103. for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
  104. const clean = line.trim();
  105. if (!clean || clean.startsWith('#')) continue;
  106. const separator = clean.indexOf('=');
  107. if (separator === -1) continue;
  108. const key = clean.slice(0, separator).trim();
  109. let value = clean.slice(separator + 1).trim();
  110. if (!key || key in env) continue;
  111. if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
  112. value = value.slice(1, -1);
  113. }
  114. env[key] = value;
  115. }
  116. return env;
  117. }
  118. export async function runVestaMaildirImport(options, {
  119. env = process.env,
  120. output = process.stdout,
  121. clock = Date.now,
  122. dbApi = defaultDbApi,
  123. inspectTarget = inspectMailHubTarget,
  124. readSnapshot = readVestaSnapshotMetadata,
  125. importSnapshot = importVestaSnapshot,
  126. hashSupported = isLegacyPasswordHash,
  127. signal = null
  128. } = {}) {
  129. const normalized = normalizeRunOptions(options);
  130. const inventory = inspectTarget({ dataDir: normalized.dataDir, user: normalized.user });
  131. const snapshot = await readSnapshot({ root: normalized.snapshot });
  132. const defaults = domainDefaults(env);
  133. const checkpointIdentity = {
  134. version: checkpointVersion,
  135. source: normalized.source,
  136. targetUserId: inventory.user.id,
  137. snapshotId: snapshotIdentifier(normalized.snapshot, snapshot)
  138. };
  139. const previousCheckpoint = normalized.dryRun
  140. ? null
  141. : await readImportCheckpoint(normalized.checkpoint, checkpointIdentity);
  142. const preflight = preflightVestaImport(snapshot, inventory, {
  143. hashSupported,
  144. defaults,
  145. allowExistingMailboxes: Boolean(previousCheckpoint)
  146. });
  147. writeCountLine(output, 'preflight', {
  148. domains: preflight.domains,
  149. new_domains: preflight.newDomains,
  150. mailboxes: preflight.mailboxes,
  151. existing_mailboxes: preflight.existingMailboxes,
  152. hashes: preflight.hashes,
  153. supported_hashes: preflight.supportedHashes,
  154. unsupported_hashes: preflight.unsupportedHashes,
  155. missing_hashes: preflight.missingHashes,
  156. conflicts: preflight.conflicts,
  157. invalid_defaults: preflight.invalidDefaults,
  158. warnings: preflight.warningCount
  159. });
  160. if (preflight.conflicts || preflight.unsupportedHashes || preflight.missingHashes || preflight.invalidDefaults) {
  161. throw new VestaImportCliError('preflight', 'Vesta 导入预检失败。');
  162. }
  163. if (normalized.dryRun) {
  164. const report = await importSnapshot({
  165. root: normalized.snapshot,
  166. dryRun: true,
  167. signal
  168. });
  169. const summary = sanitizeImportReport(report);
  170. writeCompletionLine(output, summary);
  171. return { preflight, summary, checkpoint: null };
  172. }
  173. dbApi.initDatabase(normalized.dataDir, mailHubSecret(env));
  174. const targetUser = resolveDbUser(dbApi, normalized.user);
  175. if (!targetUser || targetUser.id !== inventory.user.id || targetUser.status === 'disabled') {
  176. throw new VestaImportCliError('target', '目标用户不可用。');
  177. }
  178. // Always rescan the snapshot and let the database source key deduplicate it.
  179. // A file checkpoint cannot prove that earlier rows survived a DB rollback.
  180. const resumeCheckpoint = previousCheckpoint
  181. ? {
  182. ...previousCheckpoint,
  183. lastSourceKey: '',
  184. processed: 0,
  185. bytes: 0,
  186. complete: false
  187. }
  188. : null;
  189. const progress = {
  190. imported: 0,
  191. skipped: 0,
  192. processed: Number(resumeCheckpoint?.processed || 0),
  193. bytes: Number(resumeCheckpoint?.bytes || 0)
  194. };
  195. const checkpointWriter = createAtomicCheckpointWriter({
  196. filePath: normalized.checkpoint,
  197. identity: checkpointIdentity,
  198. initial: {
  199. ...(resumeCheckpoint || {}),
  200. warningCount: preflight.warningCount
  201. },
  202. clock
  203. });
  204. await checkpointWriter.flush({ force: true, complete: false });
  205. const adapter = createMailHubImportAdapter({
  206. dbApi,
  207. user: targetUser,
  208. source: normalized.source,
  209. defaults,
  210. progress
  211. });
  212. let report;
  213. try {
  214. report = await importSnapshot({
  215. root: normalized.snapshot,
  216. adapter,
  217. resumeAfterSourceKey: '',
  218. signal,
  219. onCheckpoint: async (sourceKey, context) => {
  220. progress.processed += 1;
  221. progress.bytes += Number(context?.message?.size || context?.message?.rawMessageBytes?.length || 0);
  222. const flushed = await checkpointWriter.record(sourceKey, progress);
  223. if (flushed) {
  224. writeCountLine(output, 'progress', {
  225. processed: progress.processed,
  226. imported: progress.imported,
  227. skipped: progress.skipped,
  228. bytes: progress.bytes
  229. });
  230. }
  231. }
  232. });
  233. } catch (error) {
  234. await checkpointWriter.flush({ force: true, complete: false });
  235. throw error;
  236. }
  237. const summary = sanitizeImportReport(report);
  238. await checkpointWriter.flush({
  239. force: true,
  240. complete: true,
  241. report: summary,
  242. fallbackSourceKey: report.lastSourceKey || resumeCheckpoint?.lastSourceKey || ''
  243. });
  244. writeCompletionLine(output, summary);
  245. return {
  246. preflight,
  247. summary,
  248. checkpoint: checkpointWriter.snapshot()
  249. };
  250. }
  251. export function inspectMailHubTarget({ dataDir, user }) {
  252. const databaseFile = path.join(path.resolve(dataDir), 'mailhub.sqlite');
  253. if (!existsSync(databaseFile)) throw new VestaImportCliError('target', '目标数据库不存在。');
  254. let database;
  255. try {
  256. database = new DatabaseSync(databaseFile, { readOnly: true });
  257. const target = parseTargetUser(user);
  258. const userRow = target.id
  259. ? database.prepare('SELECT id, username, email, status FROM users WHERE id = ?').get(target.id)
  260. : database.prepare('SELECT id, username, email, status FROM users WHERE lower(username) = ? OR lower(email) = ?').get(target.login, target.login);
  261. if (!userRow || userRow.status === 'disabled') throw new VestaImportCliError('target', '目标用户不可用。');
  262. const domains = database
  263. .prepare('SELECT id, user_id, lower(domain) AS domain FROM domains')
  264. .all()
  265. .map((row) => ({ id: Number(row.id), userId: Number(row.user_id), domain: row.domain }));
  266. const mailboxes = database
  267. .prepare(`
  268. SELECT
  269. m.id,
  270. m.user_id,
  271. lower(m.address) AS address,
  272. lower(d.domain) AS domain,
  273. m.deleted_at
  274. FROM inbound_mailboxes m
  275. JOIN domains d ON d.id = m.domain_id
  276. `)
  277. .all()
  278. .map((row) => ({
  279. id: Number(row.id),
  280. userId: Number(row.user_id),
  281. address: row.address,
  282. domain: row.domain,
  283. deletedAt: row.deleted_at || null
  284. }));
  285. return {
  286. user: {
  287. id: Number(userRow.id),
  288. username: userRow.username,
  289. email: userRow.email,
  290. status: userRow.status
  291. },
  292. domains,
  293. mailboxes
  294. };
  295. } catch (error) {
  296. if (error instanceof VestaImportCliError) throw error;
  297. throw new VestaImportCliError('target', '目标数据库无法读取。');
  298. } finally {
  299. database?.close();
  300. }
  301. }
  302. export function preflightVestaImport(snapshot, inventory, {
  303. hashSupported = isLegacyPasswordHash,
  304. defaults = {},
  305. allowExistingMailboxes = false
  306. } = {}) {
  307. const targetUserId = Number(inventory.user.id);
  308. const existingDomains = new Map(inventory.domains.map((domain) => [domain.domain, domain]));
  309. const existingMailboxes = new Map(inventory.mailboxes.map((mailbox) => [mailbox.address, mailbox]));
  310. const sourceDomains = new Set();
  311. const sourceMailboxes = new Set();
  312. let conflicts = 0;
  313. let newDomains = 0;
  314. let hashes = 0;
  315. let supportedHashes = 0;
  316. let unsupportedHashes = 0;
  317. let missingHashes = 0;
  318. let existingMailboxCount = 0;
  319. for (const domain of snapshot.domains) {
  320. const name = normalizeDomainName(domain.domain || domain.name);
  321. if (!name || sourceDomains.has(name)) conflicts += 1;
  322. else sourceDomains.add(name);
  323. const existing = existingDomains.get(name);
  324. if (existing && existing.userId !== targetUserId) conflicts += 1;
  325. if (!existing) newDomains += 1;
  326. }
  327. for (const mailbox of snapshot.mailboxes) {
  328. const address = normalizeMailboxAddress(mailbox.address);
  329. if (!address) {
  330. conflicts += 1;
  331. } else {
  332. if (sourceMailboxes.has(address)) conflicts += 1;
  333. else sourceMailboxes.add(address);
  334. const mailboxDomain = address.slice(address.lastIndexOf('@') + 1);
  335. if (!sourceDomains.has(mailboxDomain)) conflicts += 1;
  336. const existing = existingMailboxes.get(address);
  337. if (existing) {
  338. existingMailboxCount += 1;
  339. if (
  340. existing.userId !== targetUserId
  341. || existing.deletedAt
  342. || !allowExistingMailboxes
  343. ) conflicts += 1;
  344. }
  345. }
  346. const passwordHash = String(mailbox.passwordHash || mailbox.legacyPasswordHash || '').trim();
  347. if (!passwordHash) {
  348. missingHashes += 1;
  349. continue;
  350. }
  351. hashes += 1;
  352. if (hashSupported(passwordHash)) supportedHashes += 1;
  353. else unsupportedHashes += 1;
  354. }
  355. return {
  356. domains: snapshot.domains.length,
  357. newDomains,
  358. mailboxes: snapshot.mailboxes.length,
  359. existingMailboxes: existingMailboxCount,
  360. hashes,
  361. supportedHashes,
  362. unsupportedHashes,
  363. missingHashes,
  364. conflicts,
  365. invalidDefaults: newDomains > 0 && !validNewDomainDefaults(defaults) ? 1 : 0,
  366. warningCount: Array.isArray(snapshot.warnings) ? snapshot.warnings.length : 0
  367. };
  368. }
  369. export function createMailHubImportAdapter({ dbApi, user, source, defaults, progress }) {
  370. return {
  371. async ensureDomain(sourceDomain) {
  372. const domainName = normalizeDomainName(sourceDomain.domain || sourceDomain.name);
  373. const catchAllAddress = String(sourceDomain.catchAllAddress ?? sourceDomain.catchAll ?? '').trim();
  374. const existing = dbApi.getDomainByName(domainName);
  375. if (existing) {
  376. if (Number(existing.userId) !== Number(user.id)) {
  377. throw new VestaImportCliError('conflict', '现有域名归属冲突。');
  378. }
  379. return dbApi.updateDomain(existing.id, user.id, { catchAllAddress });
  380. }
  381. if (!validNewDomainDefaults(defaults)) {
  382. throw new VestaImportCliError('configuration', '新域名默认配置不可用。');
  383. }
  384. const keys = createDkimKeyPair();
  385. const created = dbApi.createDomain(user.id, {
  386. domain: domainName,
  387. selector: defaultSelector(),
  388. verificationToken: crypto.randomBytes(18).toString('hex'),
  389. dkimPublic: keys.publicKey,
  390. dkimPrivate: keys.privateKey,
  391. senderHost: defaults.senderHost,
  392. sendingIp: defaults.sendingIp,
  393. spfExtra: defaults.spfExtra,
  394. dmarcPolicy: defaults.dmarcPolicy,
  395. dmarcRua: defaults.dmarcRua
  396. });
  397. return dbApi.updateDomain(created.id, user.id, { catchAllAddress });
  398. },
  399. async ensureMailbox(mailbox) {
  400. return dbApi.upsertImportedInboundMailbox(user.id, {
  401. address: mailbox.address,
  402. displayName: mailbox.displayName,
  403. passwordHash: mailbox.passwordHash || mailbox.legacyPasswordHash || '',
  404. aliases: mailbox.aliases,
  405. forwardTo: mailbox.forwardTo,
  406. keepForwarded: mailbox.keepForwarded ?? !mailbox.forwardOnly,
  407. quotaMb: mailbox.quotaMb,
  408. status: mailbox.suspended || mailbox.status === 'suspended' ? 'disabled' : 'active'
  409. });
  410. },
  411. async ensureFolder(mailbox, folder) {
  412. return dbApi.createInboundFolder(mailbox, folder);
  413. },
  414. async hasMessage(sourceKey) {
  415. const exists = dbApi.hasImportedInboundMessage(source, sourceKey);
  416. if (exists) progress.skipped += 1;
  417. return exists;
  418. },
  419. async createMessage(message, context) {
  420. const result = dbApi.createImportedInboundMessage(context.mailbox, {
  421. ...message,
  422. importSource: source,
  423. sourceKey: message.sourceKey
  424. });
  425. if (result.created) progress.imported += 1;
  426. else progress.skipped += 1;
  427. return result;
  428. }
  429. };
  430. }
  431. export function createAtomicCheckpointWriter({
  432. filePath,
  433. identity,
  434. initial = null,
  435. clock = Date.now,
  436. messageInterval = checkpointMessageInterval,
  437. timeIntervalMs = checkpointTimeIntervalMs
  438. }) {
  439. let pending = 0;
  440. let lastFlushAt = clock();
  441. let state = {
  442. ...identity,
  443. lastSourceKey: String(initial?.lastSourceKey || ''),
  444. processed: Number(initial?.processed || 0),
  445. bytes: Number(initial?.bytes || 0),
  446. complete: Boolean(initial?.complete),
  447. warningCount: Number(initial?.warningCount || 0)
  448. };
  449. async function flush({ force = false, complete = state.complete, report = null, fallbackSourceKey = '' } = {}) {
  450. if (!force && pending < messageInterval && clock() - lastFlushAt < timeIntervalMs) return false;
  451. if (!pending && !force) return false;
  452. state = {
  453. ...state,
  454. lastSourceKey: state.lastSourceKey || String(fallbackSourceKey || ''),
  455. complete: Boolean(complete),
  456. warningCount: Number(report?.warningCount ?? state.warningCount ?? 0),
  457. updatedAt: new Date(clock()).toISOString()
  458. };
  459. await atomicWriteJson(filePath, state);
  460. pending = 0;
  461. lastFlushAt = clock();
  462. return true;
  463. }
  464. return {
  465. async record(sourceKey, progress) {
  466. state = {
  467. ...state,
  468. lastSourceKey: String(sourceKey || state.lastSourceKey || ''),
  469. processed: Number(progress.processed || 0),
  470. bytes: Number(progress.bytes || 0),
  471. complete: false
  472. };
  473. pending += 1;
  474. if (pending < messageInterval && clock() - lastFlushAt < timeIntervalMs) return false;
  475. return await flush();
  476. },
  477. flush,
  478. snapshot() {
  479. return { ...state };
  480. }
  481. };
  482. }
  483. export async function readImportCheckpoint(filePath, identity) {
  484. let checkpoint;
  485. try {
  486. checkpoint = JSON.parse(await readFile(filePath, 'utf8'));
  487. } catch (error) {
  488. if (error?.code === 'ENOENT') return null;
  489. throw new VestaImportCliError('checkpoint', '导入断点无法读取。');
  490. }
  491. if (
  492. checkpoint?.version !== identity.version
  493. || checkpoint?.source !== identity.source
  494. || Number(checkpoint?.targetUserId) !== Number(identity.targetUserId)
  495. || checkpoint?.snapshotId !== identity.snapshotId
  496. || typeof checkpoint?.lastSourceKey !== 'string'
  497. ) {
  498. throw new VestaImportCliError('checkpoint', '导入断点与本次任务不匹配。');
  499. }
  500. return checkpoint;
  501. }
  502. export function sanitizeImportReport(report) {
  503. return {
  504. dryRun: Boolean(report?.dryRun),
  505. domains: Number(report?.domains || 0),
  506. mailboxes: Number(report?.mailboxes || 0),
  507. messages: Number(report?.messages || 0),
  508. bytes: Number(report?.bytes || 0),
  509. plannedMessages: Number(report?.plannedMessages || 0),
  510. importedMessages: Number(report?.importedMessages || 0),
  511. skippedMessages: Number(report?.skippedMessages || 0),
  512. resumeSkippedMessages: Number(report?.resumeSkippedMessages || 0),
  513. warningCount: Number(report?.warningCount ?? (Array.isArray(report?.warnings) ? report.warnings.length : 0))
  514. };
  515. }
  516. export function usageText() {
  517. return [
  518. 'Usage: node scripts/import-vesta-maildir.js --snapshot PATH --data-dir PATH --user USER --source NAME [--checkpoint FILE] [--dry-run]',
  519. '',
  520. 'USER accepts a username/email, or an explicit numeric id in the form id:123.',
  521. 'Run --dry-run first. The checkpoint is written only during a real import.'
  522. ].join('\n');
  523. }
  524. async function main() {
  525. let options;
  526. try {
  527. loadMailHubEnvironment();
  528. options = parseVestaImportArguments(process.argv.slice(2));
  529. if (options.help) {
  530. process.stdout.write(`${usageText()}\n`);
  531. return;
  532. }
  533. const controller = new AbortController();
  534. const abort = () => controller.abort(new Error('Vesta 导入已取消。'));
  535. process.once('SIGINT', abort);
  536. process.once('SIGTERM', abort);
  537. try {
  538. await runVestaMaildirImport(options, { signal: controller.signal });
  539. } finally {
  540. process.off('SIGINT', abort);
  541. process.off('SIGTERM', abort);
  542. }
  543. } catch (error) {
  544. const category = safeCategory(error?.category);
  545. process.stderr.write(`failed errors=1 category=${category}\n`);
  546. process.exitCode = 1;
  547. }
  548. }
  549. function normalizeRunOptions(options = {}) {
  550. const source = normalizeImportSource(options.source);
  551. const snapshot = path.resolve(String(options.snapshot || ''));
  552. const dataDir = path.resolve(String(options.dataDir || ''));
  553. const checkpoint = path.resolve(String(options.checkpoint || path.join(dataDir, 'vesta-maildir-import.checkpoint.json')));
  554. const user = String(options.user || '').trim();
  555. if (!source || !user || !options.snapshot || !options.dataDir) {
  556. throw new VestaImportCliError('usage', '导入参数不完整。');
  557. }
  558. return { source, snapshot, dataDir, checkpoint, user, dryRun: Boolean(options.dryRun) };
  559. }
  560. function resolveDbUser(dbApi, identifier) {
  561. const target = parseTargetUser(identifier);
  562. return target.id ? dbApi.getUser(target.id) : dbApi.getUserByLogin(target.login);
  563. }
  564. function parseTargetUser(value) {
  565. const clean = String(value || '').trim().toLowerCase();
  566. const explicitId = clean.match(/^id:(\d+)$/);
  567. return explicitId
  568. ? { id: Number(explicitId[1]), login: '' }
  569. : { id: 0, login: clean };
  570. }
  571. function domainDefaults(env) {
  572. const policy = String(env.DMARC_POLICY || 'none').trim().toLowerCase();
  573. return {
  574. senderHost: String(env.MAIL_HOSTNAME || 'mailhub.local').trim().toLowerCase(),
  575. sendingIp: String(env.SENDING_IP || '').trim(),
  576. spfExtra: String(env.DEFAULT_SPF_MECHANISMS || 'include:spf.mailjet.com').trim(),
  577. dmarcPolicy: ['none', 'quarantine', 'reject'].includes(policy) ? policy : 'none',
  578. dmarcRua: String(env.DMARC_RUA || '').trim()
  579. };
  580. }
  581. function validNewDomainDefaults(defaults) {
  582. return validHostname(defaults.senderHost) && Boolean(String(defaults.sendingIp || '').trim());
  583. }
  584. function validHostname(value) {
  585. const hostname = String(value || '').trim().toLowerCase().replace(/\.$/, '');
  586. if (!hostname || hostname.length > 253) return false;
  587. return hostname.split('.').every((label) => (
  588. label.length > 0
  589. && label.length <= 63
  590. && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
  591. ));
  592. }
  593. function mailHubSecret(env) {
  594. if (env.SESSION_SECRET) return String(env.SESSION_SECRET);
  595. return crypto
  596. .createHash('sha256')
  597. .update(`${env.ADMIN_PASSWORD || 'change-this-admin-password'}:${env.API_TOKEN || ''}`)
  598. .digest('hex');
  599. }
  600. function defaultSelector() {
  601. const date = new Date();
  602. return `mh${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
  603. }
  604. function snapshotIdentifier(snapshotPath, snapshot = {}) {
  605. const fingerprint = {
  606. path: path.resolve(snapshotPath),
  607. domains: Array.isArray(snapshot.domains) ? snapshot.domains : [],
  608. mailboxes: Array.isArray(snapshot.mailboxes) ? snapshot.mailboxes : [],
  609. warnings: Array.isArray(snapshot.warnings) ? snapshot.warnings : []
  610. };
  611. return crypto
  612. .createHash('sha256')
  613. .update(JSON.stringify(sortJsonValue(fingerprint)))
  614. .digest('hex');
  615. }
  616. function sortJsonValue(value) {
  617. if (Array.isArray(value)) return value.map(sortJsonValue);
  618. if (!value || typeof value !== 'object') return value;
  619. return Object.fromEntries(
  620. Object.keys(value)
  621. .sort()
  622. .map((key) => [key, sortJsonValue(value[key])])
  623. );
  624. }
  625. function normalizeImportSource(value) {
  626. const source = String(value || '').trim().toLowerCase();
  627. if (!source || source.length > 120 || !/^[a-z0-9][a-z0-9._:@/-]*$/.test(source)) return '';
  628. return source;
  629. }
  630. function normalizeDomainName(value) {
  631. const domain = String(value || '').trim().toLowerCase();
  632. 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)
  633. ? domain
  634. : '';
  635. }
  636. function normalizeMailboxAddress(value) {
  637. const address = String(value || '').trim().toLowerCase();
  638. return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address) ? address : '';
  639. }
  640. function toCamelCase(value) {
  641. return String(value).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
  642. }
  643. function writeCompletionLine(output, summary) {
  644. writeCountLine(output, 'complete', {
  645. dry_run: summary.dryRun ? 1 : 0,
  646. domains: summary.domains,
  647. mailboxes: summary.mailboxes,
  648. messages: summary.messages,
  649. bytes: summary.bytes,
  650. planned: summary.plannedMessages,
  651. imported: summary.importedMessages,
  652. skipped: summary.skippedMessages,
  653. resume_skipped: summary.resumeSkippedMessages,
  654. warnings: summary.warningCount
  655. });
  656. }
  657. function writeCountLine(output, event, values) {
  658. const fields = Object.entries(values).map(([key, value]) => `${key}=${Number(value || 0)}`);
  659. output.write(`${event} ${fields.join(' ')}\n`);
  660. }
  661. function safeCategory(value) {
  662. const clean = String(value || 'import').trim().toLowerCase();
  663. return /^[a-z][a-z0-9_-]{0,31}$/.test(clean) ? clean : 'import';
  664. }
  665. async function atomicWriteJson(filePath, value) {
  666. const directory = path.dirname(filePath);
  667. await mkdir(directory, { recursive: true });
  668. const temporary = path.join(
  669. directory,
  670. `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`
  671. );
  672. try {
  673. await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
  674. await rename(temporary, filePath);
  675. } catch (error) {
  676. await rm(temporary, { force: true });
  677. throw error;
  678. }
  679. }
  680. const invokedUrl = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '';
  681. if (import.meta.url === invokedUrl) await main();