db.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937
  1. import { mkdirSync } from 'node:fs';
  2. import crypto from 'node:crypto';
  3. import path from 'node:path';
  4. import { DatabaseSync } from 'node:sqlite';
  5. import { dkimPublicFromPrivateKey } from './dkim.js';
  6. let db;
  7. let secretKey = '';
  8. export function initDatabase(dataDir, secret = '') {
  9. secretKey = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
  10. mkdirSync(dataDir, { recursive: true });
  11. db = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'));
  12. db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
  13. migrateLegacySmtpTable();
  14. db.exec(`
  15. CREATE TABLE IF NOT EXISTS users (
  16. id INTEGER PRIMARY KEY AUTOINCREMENT,
  17. username TEXT NOT NULL UNIQUE,
  18. email TEXT NOT NULL UNIQUE,
  19. password_hash TEXT NOT NULL,
  20. role TEXT NOT NULL DEFAULT 'user',
  21. status TEXT NOT NULL DEFAULT 'active',
  22. created_at TEXT NOT NULL,
  23. updated_at TEXT NOT NULL
  24. );
  25. CREATE TABLE IF NOT EXISTS domains (
  26. id INTEGER PRIMARY KEY AUTOINCREMENT,
  27. user_id INTEGER,
  28. dns_credential_id INTEGER,
  29. domain TEXT NOT NULL UNIQUE,
  30. selector TEXT NOT NULL,
  31. verification_token TEXT NOT NULL,
  32. dkim_public TEXT NOT NULL,
  33. dkim_private TEXT NOT NULL,
  34. sender_host TEXT NOT NULL,
  35. sending_ip TEXT NOT NULL,
  36. spf_extra TEXT NOT NULL DEFAULT '',
  37. dmarc_policy TEXT NOT NULL DEFAULT 'none',
  38. dmarc_rua TEXT NOT NULL DEFAULT '',
  39. status_json TEXT NOT NULL DEFAULT '{}',
  40. created_at TEXT NOT NULL,
  41. updated_at TEXT NOT NULL
  42. );
  43. CREATE TABLE IF NOT EXISTS send_events (
  44. id INTEGER PRIMARY KEY AUTOINCREMENT,
  45. user_id INTEGER,
  46. domain_id INTEGER,
  47. sender TEXT NOT NULL,
  48. recipients TEXT NOT NULL,
  49. subject TEXT NOT NULL,
  50. status TEXT NOT NULL,
  51. detail TEXT NOT NULL DEFAULT '',
  52. created_at TEXT NOT NULL,
  53. FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE SET NULL
  54. );
  55. CREATE TABLE IF NOT EXISTS smtp_credentials (
  56. id INTEGER PRIMARY KEY AUTOINCREMENT,
  57. user_id INTEGER NOT NULL UNIQUE,
  58. username TEXT NOT NULL UNIQUE,
  59. password_hash TEXT NOT NULL,
  60. password_secret TEXT NOT NULL DEFAULT '',
  61. created_at TEXT NOT NULL,
  62. updated_at TEXT NOT NULL,
  63. FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
  64. );
  65. CREATE TABLE IF NOT EXISTS api_tokens (
  66. id INTEGER PRIMARY KEY AUTOINCREMENT,
  67. user_id INTEGER NOT NULL,
  68. name TEXT NOT NULL,
  69. token_hash TEXT NOT NULL UNIQUE,
  70. token_prefix TEXT NOT NULL,
  71. last_used_at TEXT,
  72. created_at TEXT NOT NULL,
  73. FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
  74. );
  75. CREATE TABLE IF NOT EXISTS dns_credentials (
  76. id INTEGER PRIMARY KEY AUTOINCREMENT,
  77. user_id INTEGER NOT NULL,
  78. name TEXT NOT NULL,
  79. provider TEXT NOT NULL,
  80. zone_name TEXT NOT NULL DEFAULT '',
  81. default_ttl INTEGER NOT NULL DEFAULT 600,
  82. credentials_secret TEXT NOT NULL,
  83. created_at TEXT NOT NULL,
  84. updated_at TEXT NOT NULL,
  85. FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
  86. );
  87. CREATE TABLE IF NOT EXISTS app_settings (
  88. key TEXT PRIMARY KEY,
  89. value TEXT NOT NULL,
  90. updated_at TEXT NOT NULL
  91. );
  92. CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON api_tokens(user_id);
  93. CREATE INDEX IF NOT EXISTS idx_dns_credentials_user_id ON dns_credentials(user_id);
  94. `);
  95. ensureColumn('domains', 'user_id', 'INTEGER');
  96. ensureColumn('domains', 'dns_credential_id', 'INTEGER');
  97. ensureColumn('send_events', 'user_id', 'INTEGER');
  98. ensureColumn('smtp_credentials', 'password_secret', "TEXT NOT NULL DEFAULT ''");
  99. db.exec(`
  100. CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
  101. CREATE INDEX IF NOT EXISTS idx_events_user_id ON send_events(user_id);
  102. `);
  103. normalizeDkimPublicKeys();
  104. return db;
  105. }
  106. export function seedAdminUser({ username, password, email }) {
  107. const normalizedUsername = normalizeUsername(username || 'admin');
  108. const normalizedEmail = normalizeEmail(email || `${normalizedUsername}@mailhub.local`);
  109. const existing = getUserByLogin(normalizedUsername) || getUserByLogin(normalizedEmail);
  110. if (existing) {
  111. requireDb()
  112. .prepare('UPDATE users SET role = ?, status = ?, updated_at = ? WHERE id = ?')
  113. .run('admin', 'active', now(), existing.id);
  114. return getUser(existing.id);
  115. }
  116. return createUser({
  117. username: normalizedUsername,
  118. email: normalizedEmail,
  119. password,
  120. role: 'admin'
  121. });
  122. }
  123. export function claimLegacyData(userId) {
  124. requireDb().prepare('UPDATE domains SET user_id = ? WHERE user_id IS NULL').run(userId);
  125. requireDb().prepare(`
  126. UPDATE send_events
  127. SET user_id = COALESCE((SELECT user_id FROM domains WHERE domains.id = send_events.domain_id), ?)
  128. WHERE user_id IS NULL
  129. `).run(userId);
  130. if (!tableExists('smtp_credentials_legacy') || getSmtpCredential(userId)) return;
  131. const legacy = requireDb().prepare('SELECT * FROM smtp_credentials_legacy WHERE id = 1').get();
  132. if (!legacy?.username || !legacy?.password_hash) return;
  133. const createdAt = now();
  134. requireDb()
  135. .prepare(`
  136. INSERT INTO smtp_credentials (user_id, username, password_hash, password_secret, created_at, updated_at)
  137. VALUES (?, ?, ?, ?, ?, ?)
  138. `)
  139. .run(userId, legacy.username, legacy.password_hash, legacy.password_secret || '', createdAt, createdAt);
  140. }
  141. export function seedSmtpCredential(userId, username, password) {
  142. if (!userId || !username || !password || getSmtpCredential(userId)) return null;
  143. return saveSmtpCredential(userId, { username, password });
  144. }
  145. export function createUser({ username, email, password, role = 'user' }) {
  146. const cleanUsername = normalizeUsername(username);
  147. const cleanEmail = normalizeEmail(email);
  148. if (!cleanUsername) throw new Error('用户名格式不正确。');
  149. if (!cleanEmail) throw new Error('邮箱格式不正确。');
  150. if (String(password || '').length < 8) throw new Error('密码至少需要 8 位。');
  151. const createdAt = now();
  152. const result = requireDb()
  153. .prepare(`
  154. INSERT INTO users (username, email, password_hash, role, status, created_at, updated_at)
  155. VALUES (?, ?, ?, ?, 'active', ?, ?)
  156. `)
  157. .run(cleanUsername, cleanEmail, hashPassword(password), role === 'admin' ? 'admin' : 'user', createdAt, createdAt);
  158. return getUser(result.lastInsertRowid);
  159. }
  160. export function authenticateUser(login, password) {
  161. const user = getUserByLogin(login, { includeHash: true });
  162. if (!user || user.status !== 'active' || !verifyPassword(password, user.passwordHash)) return null;
  163. return publicUser(user);
  164. }
  165. export function listUsers() {
  166. return requireDb()
  167. .prepare('SELECT * FROM users ORDER BY created_at DESC')
  168. .all()
  169. .map(publicUser);
  170. }
  171. export function getUser(id, { includeHash = false } = {}) {
  172. const row = requireDb().prepare('SELECT * FROM users WHERE id = ?').get(id);
  173. return includeHash ? privateUser(row) : publicUser(row);
  174. }
  175. export function getUserByLogin(login, { includeHash = false } = {}) {
  176. const value = String(login || '').trim().toLowerCase();
  177. if (!value) return null;
  178. const row = requireDb()
  179. .prepare('SELECT * FROM users WHERE lower(username) = ? OR lower(email) = ?')
  180. .get(value, value);
  181. return includeHash ? privateUser(row) : publicUser(row);
  182. }
  183. export function updateUser(id, patch) {
  184. const current = getUser(id, { includeHash: true });
  185. if (!current) return null;
  186. const next = {
  187. role: patch.role === 'admin' ? 'admin' : current.role,
  188. status: ['active', 'disabled'].includes(patch.status) ? patch.status : current.status,
  189. passwordHash: patch.password ? hashPassword(patch.password) : current.passwordHash,
  190. updatedAt: now()
  191. };
  192. requireDb()
  193. .prepare('UPDATE users SET role = ?, status = ?, password_hash = ?, updated_at = ? WHERE id = ?')
  194. .run(next.role, next.status, next.passwordHash, next.updatedAt, id);
  195. return getUser(id);
  196. }
  197. export function getAdminUser() {
  198. const row = requireDb()
  199. .prepare("SELECT * FROM users WHERE role = 'admin' AND status = 'active' ORDER BY id LIMIT 1")
  200. .get();
  201. return publicUser(row);
  202. }
  203. export function listDomains(userId) {
  204. return requireDb()
  205. .prepare('SELECT * FROM domains WHERE user_id = ? ORDER BY created_at DESC')
  206. .all(userId)
  207. .map(publicDomainRow);
  208. }
  209. export function getDomain(id, { userId, includePrivate = false } = {}) {
  210. const row = requireDb()
  211. .prepare('SELECT * FROM domains WHERE id = ? AND (? IS NULL OR user_id = ?)')
  212. .get(id, userId ?? null, userId ?? null);
  213. return includePrivate ? privateDomainRow(row) : publicDomainRow(row);
  214. }
  215. export function getDomainByName(domain, { userId, includePrivate = false } = {}) {
  216. const row = requireDb()
  217. .prepare('SELECT * FROM domains WHERE domain = ? AND (? IS NULL OR user_id = ?)')
  218. .get(String(domain || '').toLowerCase(), userId ?? null, userId ?? null);
  219. return includePrivate ? privateDomainRow(row) : publicDomainRow(row);
  220. }
  221. export function createDomain(userId, domain) {
  222. const createdAt = now();
  223. const result = requireDb()
  224. .prepare(`
  225. INSERT INTO domains (
  226. user_id, dns_credential_id, domain, selector, verification_token,
  227. dkim_public, dkim_private, sender_host, sending_ip, spf_extra,
  228. dmarc_policy, dmarc_rua, status_json, created_at, updated_at
  229. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)
  230. `)
  231. .run(
  232. userId,
  233. domain.dnsCredentialId || null,
  234. domain.domain,
  235. domain.selector,
  236. domain.verificationToken,
  237. domain.dkimPublic,
  238. domain.dkimPrivate,
  239. domain.senderHost,
  240. domain.sendingIp,
  241. domain.spfExtra,
  242. domain.dmarcPolicy,
  243. domain.dmarcRua,
  244. createdAt,
  245. createdAt
  246. );
  247. return getDomain(result.lastInsertRowid, { userId });
  248. }
  249. export function updateDomain(id, userId, patch) {
  250. const current = getDomain(id, { userId, includePrivate: true });
  251. if (!current) return null;
  252. const next = {
  253. selector: patch.selector ?? current.selector,
  254. dnsCredentialId: patch.dnsCredentialId === undefined ? current.dnsCredentialId : (patch.dnsCredentialId || null),
  255. senderHost: patch.senderHost ?? current.senderHost,
  256. sendingIp: patch.sendingIp ?? current.sendingIp,
  257. spfExtra: patch.spfExtra ?? current.spfExtra,
  258. dmarcPolicy: patch.dmarcPolicy ?? current.dmarcPolicy,
  259. dmarcRua: patch.dmarcRua ?? current.dmarcRua,
  260. updatedAt: now()
  261. };
  262. requireDb()
  263. .prepare(`
  264. UPDATE domains
  265. SET selector = ?, dns_credential_id = ?, sender_host = ?, sending_ip = ?, spf_extra = ?,
  266. dmarc_policy = ?, dmarc_rua = ?, updated_at = ?
  267. WHERE id = ? AND user_id = ?
  268. `)
  269. .run(
  270. next.selector,
  271. next.dnsCredentialId,
  272. next.senderHost,
  273. next.sendingIp,
  274. next.spfExtra,
  275. next.dmarcPolicy,
  276. next.dmarcRua,
  277. next.updatedAt,
  278. id,
  279. userId
  280. );
  281. return getDomain(id, { userId });
  282. }
  283. export function updateDkim(id, userId, keys, selector) {
  284. requireDb()
  285. .prepare('UPDATE domains SET selector = ?, dkim_public = ?, dkim_private = ?, updated_at = ? WHERE id = ? AND user_id = ?')
  286. .run(selector, keys.publicKey, keys.privateKey, now(), id, userId);
  287. return getDomain(id, { userId });
  288. }
  289. export function saveDomainStatus(id, userId, status) {
  290. requireDb()
  291. .prepare('UPDATE domains SET status_json = ?, updated_at = ? WHERE id = ? AND user_id = ?')
  292. .run(JSON.stringify(status), now(), id, userId);
  293. }
  294. export function deleteDomain(id, userId) {
  295. const result = requireDb().prepare('DELETE FROM domains WHERE id = ? AND user_id = ?').run(id, userId);
  296. return result.changes > 0;
  297. }
  298. export function logSendEvent(event) {
  299. const result = requireDb()
  300. .prepare(`
  301. INSERT INTO send_events (user_id, domain_id, sender, recipients, subject, status, detail, created_at)
  302. VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  303. `)
  304. .run(
  305. event.userId ?? null,
  306. event.domainId ?? null,
  307. event.sender,
  308. JSON.stringify(event.recipients),
  309. event.subject,
  310. event.status,
  311. event.detail ?? '',
  312. now()
  313. );
  314. return result.lastInsertRowid;
  315. }
  316. export function listSendEvents(userId, limit = 30) {
  317. return requireDb()
  318. .prepare(`
  319. SELECT e.*, d.domain
  320. FROM send_events e
  321. LEFT JOIN domains d ON d.id = e.domain_id
  322. WHERE e.user_id = ?
  323. ORDER BY e.created_at DESC
  324. LIMIT ?
  325. `)
  326. .all(userId, limit)
  327. .map((row) => ({
  328. id: row.id,
  329. userId: row.user_id,
  330. domainId: row.domain_id,
  331. domain: row.domain,
  332. sender: row.sender,
  333. recipients: safeJson(row.recipients, []),
  334. subject: row.subject,
  335. status: row.status,
  336. detail: row.detail,
  337. createdAt: row.created_at
  338. }));
  339. }
  340. export function getSendAnalytics(userId, { days = 30 } = {}) {
  341. const windowDays = clampAnalyticsDays(days);
  342. const since = new Date();
  343. since.setUTCHours(0, 0, 0, 0);
  344. since.setUTCDate(since.getUTCDate() - (windowDays - 1));
  345. const rows = requireDb()
  346. .prepare(`
  347. SELECT e.*, d.domain
  348. FROM send_events e
  349. LEFT JOIN domains d ON d.id = e.domain_id
  350. WHERE e.user_id = ? AND e.created_at >= ?
  351. ORDER BY e.created_at ASC
  352. LIMIT 5000
  353. `)
  354. .all(userId, since.toISOString());
  355. const domains = listDomains(userId);
  356. const dayBuckets = buildDayBuckets(windowDays);
  357. const byStatus = {};
  358. const byDomain = new Map();
  359. const hourly = Array.from({ length: 24 }, (_, hour) => ({
  360. hour,
  361. total: 0,
  362. queued: 0,
  363. failed: 0
  364. }));
  365. let recipients = 0;
  366. let queued = 0;
  367. let failed = 0;
  368. let today = 0;
  369. let last7Days = 0;
  370. const todayKey = new Date().toISOString().slice(0, 10);
  371. const weekStart = new Date();
  372. weekStart.setUTCHours(0, 0, 0, 0);
  373. weekStart.setUTCDate(weekStart.getUTCDate() - 6);
  374. for (const row of rows) {
  375. const status = row.status || 'unknown';
  376. const recipientList = safeJson(row.recipients, []);
  377. const recipientCount = Array.isArray(recipientList) ? recipientList.length : 0;
  378. const domainName = row.domain || (String(row.sender || '').split('@')[1] || 'unknown');
  379. const createdAt = new Date(row.created_at);
  380. const dayKey = row.created_at.slice(0, 10);
  381. const hour = Number.isInteger(createdAt.getUTCHours()) ? createdAt.getUTCHours() : 0;
  382. const isQueued = status === 'queued';
  383. recipients += recipientCount;
  384. queued += isQueued ? 1 : 0;
  385. failed += isQueued ? 0 : 1;
  386. today += dayKey === todayKey ? 1 : 0;
  387. last7Days += createdAt >= weekStart ? 1 : 0;
  388. byStatus[status] = (byStatus[status] || 0) + 1;
  389. if (dayBuckets.has(dayKey)) {
  390. const bucket = dayBuckets.get(dayKey);
  391. bucket.total += 1;
  392. bucket.queued += isQueued ? 1 : 0;
  393. bucket.failed += isQueued ? 0 : 1;
  394. bucket.recipients += recipientCount;
  395. }
  396. const domainBucket = byDomain.get(domainName) || {
  397. domain: domainName,
  398. total: 0,
  399. queued: 0,
  400. failed: 0,
  401. recipients: 0
  402. };
  403. domainBucket.total += 1;
  404. domainBucket.queued += isQueued ? 1 : 0;
  405. domainBucket.failed += isQueued ? 0 : 1;
  406. domainBucket.recipients += recipientCount;
  407. byDomain.set(domainName, domainBucket);
  408. hourly[hour].total += 1;
  409. hourly[hour].queued += isQueued ? 1 : 0;
  410. hourly[hour].failed += isQueued ? 0 : 1;
  411. }
  412. const recentFailures = [...rows]
  413. .reverse()
  414. .filter((row) => row.status !== 'queued')
  415. .slice(0, 8)
  416. .map((row) => ({
  417. id: row.id,
  418. domain: row.domain || '',
  419. sender: row.sender,
  420. subject: row.subject,
  421. detail: row.detail,
  422. createdAt: row.created_at
  423. }));
  424. return {
  425. windowDays,
  426. summary: {
  427. total: rows.length,
  428. queued,
  429. failed,
  430. recipients,
  431. today,
  432. last7Days,
  433. successRate: rows.length ? Math.round((queued / rows.length) * 1000) / 10 : 0,
  434. domains: domains.length,
  435. verifiedDomains: domains.filter((domain) => domain.status?.verified).length
  436. },
  437. byDay: [...dayBuckets.values()],
  438. byDomain: [...byDomain.values()].sort((a, b) => b.total - a.total).slice(0, 10),
  439. byStatus: Object.entries(byStatus).map(([status, total]) => ({ status, total })),
  440. hourly,
  441. recentFailures
  442. };
  443. }
  444. export function getSmtpCredential(userId, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
  445. const row = requireDb()
  446. .prepare('SELECT * FROM smtp_credentials WHERE user_id = ?')
  447. .get(userId);
  448. if (!row) return null;
  449. return publicSmtpCredential(row, { includeHash, includePassword, includeSecret });
  450. }
  451. export function saveSmtpCredential(userId, { username, password }) {
  452. const current = getSmtpCredential(userId, { includeHash: true, includeSecret: true });
  453. const nextUsername = String(username || current?.username || '').trim();
  454. if (!nextUsername) throw new Error('SMTP 用户名不能为空。');
  455. const nextHash = password ? hashPassword(password) : current?.passwordHash;
  456. if (!nextHash) throw new Error('SMTP 密码不能为空。');
  457. const nextSecret = password ? encryptSecret(password) : current?.passwordSecret || '';
  458. const updatedAt = now();
  459. if (current) {
  460. requireDb()
  461. .prepare('UPDATE smtp_credentials SET username = ?, password_hash = ?, password_secret = ?, updated_at = ? WHERE user_id = ?')
  462. .run(nextUsername, nextHash, nextSecret, updatedAt, userId);
  463. } else {
  464. requireDb()
  465. .prepare(`
  466. INSERT INTO smtp_credentials (user_id, username, password_hash, password_secret, created_at, updated_at)
  467. VALUES (?, ?, ?, ?, ?, ?)
  468. `)
  469. .run(userId, nextUsername, nextHash, nextSecret, updatedAt, updatedAt);
  470. }
  471. return getSmtpCredential(userId);
  472. }
  473. export function verifySmtpCredential(username, password) {
  474. const row = requireDb()
  475. .prepare(`
  476. SELECT c.*, u.id AS auth_user_id, u.username AS auth_username, u.email, u.role, u.status
  477. FROM smtp_credentials c
  478. JOIN users u ON u.id = c.user_id
  479. WHERE c.username = ?
  480. `)
  481. .get(String(username || '').trim());
  482. if (!row || row.status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
  483. return {
  484. user: {
  485. id: row.auth_user_id,
  486. username: row.auth_username,
  487. email: row.email,
  488. role: row.role,
  489. status: row.status
  490. },
  491. credential: publicSmtpCredential(row)
  492. };
  493. }
  494. export function createApiToken(userId, name) {
  495. const token = `mh_${crypto.randomBytes(32).toString('base64url')}`;
  496. const createdAt = now();
  497. const result = requireDb()
  498. .prepare(`
  499. INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, created_at)
  500. VALUES (?, ?, ?, ?, ?)
  501. `)
  502. .run(userId, String(name || 'API Token').trim() || 'API Token', tokenHash(token), token.slice(0, 12), createdAt);
  503. return {
  504. ...getApiToken(result.lastInsertRowid, userId),
  505. token
  506. };
  507. }
  508. export function listApiTokens(userId) {
  509. return requireDb()
  510. .prepare('SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC')
  511. .all(userId)
  512. .map(publicApiToken);
  513. }
  514. export function getApiToken(id, userId) {
  515. const row = requireDb()
  516. .prepare('SELECT * FROM api_tokens WHERE id = ? AND user_id = ?')
  517. .get(id, userId);
  518. return publicApiToken(row);
  519. }
  520. export function deleteApiToken(id, userId) {
  521. const result = requireDb().prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(id, userId);
  522. return result.changes > 0;
  523. }
  524. export function verifyApiToken(token) {
  525. const hash = tokenHash(token);
  526. const row = requireDb()
  527. .prepare(`
  528. SELECT t.*, u.id AS auth_user_id, u.username, u.email, u.role, u.status
  529. FROM api_tokens t
  530. JOIN users u ON u.id = t.user_id
  531. WHERE t.token_hash = ?
  532. `)
  533. .get(hash);
  534. if (!row || row.status !== 'active') return null;
  535. requireDb().prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id);
  536. return {
  537. id: row.auth_user_id,
  538. username: row.username,
  539. email: row.email,
  540. role: row.role,
  541. status: row.status
  542. };
  543. }
  544. export function listDnsCredentials(userId) {
  545. return requireDb()
  546. .prepare('SELECT * FROM dns_credentials WHERE user_id = ? ORDER BY created_at DESC')
  547. .all(userId)
  548. .map(publicDnsCredential);
  549. }
  550. export function getDnsCredential(id, userId, { includeCredentials = false } = {}) {
  551. const row = requireDb()
  552. .prepare('SELECT * FROM dns_credentials WHERE id = ? AND user_id = ?')
  553. .get(id, userId);
  554. if (!row) return null;
  555. const publicRow = publicDnsCredential(row);
  556. if (!includeCredentials) return publicRow;
  557. return {
  558. ...publicRow,
  559. credentials: safeJson(decryptSecret(row.credentials_secret), {})
  560. };
  561. }
  562. export function saveDnsCredential(userId, credential) {
  563. const provider = normalizeProvider(credential.provider);
  564. if (!provider) throw new Error('DNS 服务商不支持。');
  565. const name = String(credential.name || provider).trim();
  566. const zoneName = String(credential.zoneName || credential.zone || '').trim().toLowerCase();
  567. const defaultTtl = clampTtl(credential.defaultTtl);
  568. const credentials = credential.credentials || pickCredentialFields(credential);
  569. const updatedAt = now();
  570. if (credential.id) {
  571. const current = getDnsCredential(credential.id, userId, { includeCredentials: true });
  572. if (!current) return null;
  573. const nextCredentials = Object.keys(credentials).length ? credentials : current.credentials;
  574. requireDb()
  575. .prepare(`
  576. UPDATE dns_credentials
  577. SET name = ?, provider = ?, zone_name = ?, default_ttl = ?, credentials_secret = ?, updated_at = ?
  578. WHERE id = ? AND user_id = ?
  579. `)
  580. .run(name, provider, zoneName, defaultTtl, encryptSecret(JSON.stringify(nextCredentials)), updatedAt, credential.id, userId);
  581. return getDnsCredential(credential.id, userId);
  582. }
  583. const result = requireDb()
  584. .prepare(`
  585. INSERT INTO dns_credentials (user_id, name, provider, zone_name, default_ttl, credentials_secret, created_at, updated_at)
  586. VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  587. `)
  588. .run(userId, name, provider, zoneName, defaultTtl, encryptSecret(JSON.stringify(credentials)), updatedAt, updatedAt);
  589. return getDnsCredential(result.lastInsertRowid, userId);
  590. }
  591. export function deleteDnsCredential(id, userId) {
  592. requireDb().prepare('UPDATE domains SET dns_credential_id = NULL WHERE dns_credential_id = ? AND user_id = ?').run(id, userId);
  593. const result = requireDb().prepare('DELETE FROM dns_credentials WHERE id = ? AND user_id = ?').run(id, userId);
  594. return result.changes > 0;
  595. }
  596. export function getSettings(defaults = {}) {
  597. const rows = requireDb().prepare('SELECT * FROM app_settings').all();
  598. const values = { ...defaults };
  599. for (const row of rows) values[row.key] = row.value;
  600. return values;
  601. }
  602. export function saveSettings(patch) {
  603. const allowed = new Set([
  604. 'appBaseUrl',
  605. 'mailHostname',
  606. 'sendingIp',
  607. 'defaultSpfMechanisms',
  608. 'dmarcPolicy',
  609. 'dmarcRua',
  610. 'sendRequiresVerified'
  611. ]);
  612. const updatedAt = now();
  613. for (const [key, value] of Object.entries(patch)) {
  614. if (!allowed.has(key)) continue;
  615. requireDb()
  616. .prepare(`
  617. INSERT INTO app_settings (key, value, updated_at)
  618. VALUES (?, ?, ?)
  619. ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
  620. `)
  621. .run(key, String(value ?? ''), updatedAt);
  622. }
  623. return getSettings();
  624. }
  625. function migrateLegacySmtpTable() {
  626. if (!tableExists('smtp_credentials') || columnExists('smtp_credentials', 'user_id')) return;
  627. if (!tableExists('smtp_credentials_legacy')) {
  628. requireDb().exec('ALTER TABLE smtp_credentials RENAME TO smtp_credentials_legacy;');
  629. } else {
  630. requireDb().exec('DROP TABLE smtp_credentials;');
  631. }
  632. }
  633. function requireDb() {
  634. if (!db) throw new Error('Database is not initialized.');
  635. return db;
  636. }
  637. function tableExists(table) {
  638. return Boolean(requireDb().prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table));
  639. }
  640. function columnExists(table, column) {
  641. if (!tableExists(table)) return false;
  642. return requireDb()
  643. .prepare(`PRAGMA table_info(${table})`)
  644. .all()
  645. .some((row) => row.name === column);
  646. }
  647. function ensureColumn(table, column, definition) {
  648. if (!columnExists(table, column)) requireDb().exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
  649. }
  650. function normalizeDkimPublicKeys() {
  651. if (!tableExists('domains') || !columnExists('domains', 'dkim_private') || !columnExists('domains', 'dkim_public')) return;
  652. const rows = requireDb().prepare('SELECT id, dkim_public, dkim_private FROM domains').all();
  653. const update = requireDb().prepare('UPDATE domains SET dkim_public = ?, updated_at = ? WHERE id = ?');
  654. for (const row of rows) {
  655. if (!row.dkim_private) continue;
  656. try {
  657. const publicKey = dkimPublicFromPrivateKey(row.dkim_private);
  658. if (publicKey && publicKey !== row.dkim_public) update.run(publicKey, now(), row.id);
  659. } catch {
  660. // Leave legacy or malformed rows untouched; rotating DKIM from the UI can repair them.
  661. }
  662. }
  663. }
  664. function publicUser(row) {
  665. if (!row) return null;
  666. return {
  667. id: row.id,
  668. username: row.username,
  669. email: row.email,
  670. role: row.role,
  671. status: row.status,
  672. createdAt: row.created_at,
  673. updatedAt: row.updated_at
  674. };
  675. }
  676. function privateUser(row) {
  677. const user = publicUser(row);
  678. return user ? { ...user, passwordHash: row.password_hash } : null;
  679. }
  680. function publicDomainRow(row) {
  681. if (!row) return null;
  682. return {
  683. id: row.id,
  684. userId: row.user_id,
  685. dnsCredentialId: row.dns_credential_id,
  686. domain: row.domain,
  687. selector: row.selector,
  688. verificationToken: row.verification_token,
  689. dkimPublic: row.dkim_public,
  690. senderHost: row.sender_host,
  691. sendingIp: row.sending_ip,
  692. spfExtra: row.spf_extra,
  693. dmarcPolicy: row.dmarc_policy,
  694. dmarcRua: row.dmarc_rua,
  695. status: safeJson(row.status_json, {}),
  696. createdAt: row.created_at,
  697. updatedAt: row.updated_at
  698. };
  699. }
  700. function privateDomainRow(row) {
  701. const publicRow = publicDomainRow(row);
  702. return publicRow ? { ...publicRow, dkimPrivate: row.dkim_private } : null;
  703. }
  704. function publicSmtpCredential(row, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
  705. if (!row) return null;
  706. const password = includePassword ? decryptSecret(row.password_secret) : '';
  707. const passwordRecoverable = Boolean(row.password_secret && (password || decryptSecret(row.password_secret)));
  708. return {
  709. id: row.id,
  710. userId: row.user_id,
  711. username: row.username,
  712. passwordSet: Boolean(row.password_hash),
  713. passwordRecoverable,
  714. ...(includePassword ? { password } : {}),
  715. ...(includeHash ? { passwordHash: row.password_hash } : {}),
  716. ...(includeSecret ? { passwordSecret: row.password_secret } : {}),
  717. createdAt: row.created_at,
  718. updatedAt: row.updated_at
  719. };
  720. }
  721. function publicApiToken(row) {
  722. if (!row) return null;
  723. return {
  724. id: row.id,
  725. userId: row.user_id,
  726. name: row.name,
  727. tokenPrefix: row.token_prefix,
  728. lastUsedAt: row.last_used_at,
  729. createdAt: row.created_at
  730. };
  731. }
  732. function publicDnsCredential(row) {
  733. if (!row) return null;
  734. return {
  735. id: row.id,
  736. userId: row.user_id,
  737. name: row.name,
  738. provider: row.provider,
  739. zoneName: row.zone_name,
  740. defaultTtl: row.default_ttl,
  741. credentialSet: Boolean(row.credentials_secret),
  742. createdAt: row.created_at,
  743. updatedAt: row.updated_at
  744. };
  745. }
  746. function normalizeUsername(value) {
  747. const username = String(value || '').trim().toLowerCase();
  748. return /^[a-z0-9][a-z0-9_.-]{2,31}$/.test(username) ? username : '';
  749. }
  750. function normalizeEmail(value) {
  751. const email = String(value || '').trim().toLowerCase();
  752. return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : '';
  753. }
  754. function normalizeProvider(value) {
  755. const provider = String(value || '').trim().toLowerCase();
  756. return ['cloudflare', 'aliyun', 'dnspod'].includes(provider) ? provider : '';
  757. }
  758. function pickCredentialFields(source) {
  759. const output = {};
  760. for (const key of ['apiToken', 'zoneId', 'accessKeyId', 'accessKeySecret', 'secretId', 'secretKey']) {
  761. if (source[key]) output[key] = String(source[key]).trim();
  762. }
  763. return output;
  764. }
  765. function clampTtl(value) {
  766. const ttl = Number(value || 600);
  767. if (!Number.isInteger(ttl) || ttl < 60) return 600;
  768. if (ttl > 86400) return 86400;
  769. return ttl;
  770. }
  771. function clampAnalyticsDays(value) {
  772. const days = Number(value || 30);
  773. if (!Number.isInteger(days) || days < 7) return 30;
  774. if (days > 90) return 90;
  775. return days;
  776. }
  777. function buildDayBuckets(days) {
  778. const buckets = new Map();
  779. const cursor = new Date();
  780. cursor.setUTCHours(0, 0, 0, 0);
  781. cursor.setUTCDate(cursor.getUTCDate() - (days - 1));
  782. for (let index = 0; index < days; index += 1) {
  783. const date = new Date(cursor);
  784. date.setUTCDate(cursor.getUTCDate() + index);
  785. const day = date.toISOString().slice(0, 10);
  786. buckets.set(day, {
  787. day,
  788. total: 0,
  789. queued: 0,
  790. failed: 0,
  791. recipients: 0
  792. });
  793. }
  794. return buckets;
  795. }
  796. function safeJson(value, fallback) {
  797. try {
  798. return JSON.parse(value);
  799. } catch {
  800. return fallback;
  801. }
  802. }
  803. function now() {
  804. return new Date().toISOString();
  805. }
  806. function tokenHash(token) {
  807. return crypto.createHash('sha256').update(String(token || '')).digest('hex');
  808. }
  809. function hashPassword(password) {
  810. const salt = crypto.randomBytes(16).toString('hex');
  811. const hash = crypto.scryptSync(String(password), salt, 64).toString('hex');
  812. return `scrypt$${salt}$${hash}`;
  813. }
  814. function verifyPassword(password, stored) {
  815. const [scheme, salt, hash] = String(stored || '').split('$');
  816. if (scheme !== 'scrypt' || !salt || !hash) return false;
  817. const actual = crypto.scryptSync(String(password), salt, 64).toString('hex');
  818. return safeEqual(actual, hash);
  819. }
  820. function encryptSecret(value) {
  821. if (!value) return '';
  822. const iv = crypto.randomBytes(12);
  823. const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv);
  824. const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
  825. return [
  826. 'v1',
  827. iv.toString('base64url'),
  828. cipher.getAuthTag().toString('base64url'),
  829. encrypted.toString('base64url')
  830. ].join('$');
  831. }
  832. function decryptSecret(secret) {
  833. const [version, ivRaw, tagRaw, encryptedRaw] = String(secret || '').split('$');
  834. if (version !== 'v1' || !ivRaw || !tagRaw || !encryptedRaw) return '';
  835. try {
  836. const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivRaw, 'base64url'));
  837. decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
  838. return Buffer.concat([
  839. decipher.update(Buffer.from(encryptedRaw, 'base64url')),
  840. decipher.final()
  841. ]).toString('utf8');
  842. } catch {
  843. return '';
  844. }
  845. }
  846. function encryptionKey() {
  847. return crypto
  848. .createHash('sha256')
  849. .update(secretKey || 'mailhub-local-secret')
  850. .digest();
  851. }
  852. function safeEqual(actual, expected) {
  853. const a = Buffer.from(String(actual || ''));
  854. const b = Buffer.from(String(expected || ''));
  855. if (a.length !== b.length) return false;
  856. return crypto.timingSafeEqual(a, b);
  857. }