| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937 |
- import { mkdirSync } from 'node:fs';
- import crypto from 'node:crypto';
- import path from 'node:path';
- import { DatabaseSync } from 'node:sqlite';
- import { dkimPublicFromPrivateKey } from './dkim.js';
- let db;
- let secretKey = '';
- 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'));
- db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
- migrateLegacySmtpTable();
- db.exec(`
- CREATE TABLE IF NOT EXISTS users (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- username TEXT NOT NULL UNIQUE,
- email TEXT NOT NULL UNIQUE,
- password_hash TEXT NOT NULL,
- role TEXT NOT NULL DEFAULT 'user',
- status TEXT NOT NULL DEFAULT 'active',
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
- );
- CREATE TABLE IF NOT EXISTS domains (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER,
- dns_credential_id INTEGER,
- domain TEXT NOT NULL UNIQUE,
- selector TEXT NOT NULL,
- verification_token TEXT NOT NULL,
- dkim_public TEXT NOT NULL,
- dkim_private TEXT NOT NULL,
- sender_host TEXT NOT NULL,
- sending_ip TEXT NOT NULL,
- spf_extra TEXT NOT NULL DEFAULT '',
- dmarc_policy TEXT NOT NULL DEFAULT 'none',
- dmarc_rua TEXT NOT NULL DEFAULT '',
- status_json TEXT NOT NULL DEFAULT '{}',
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
- );
- CREATE TABLE IF NOT EXISTS send_events (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER,
- domain_id INTEGER,
- sender TEXT NOT NULL,
- recipients TEXT NOT NULL,
- subject TEXT NOT NULL,
- status TEXT NOT NULL,
- detail TEXT NOT NULL DEFAULT '',
- created_at TEXT NOT NULL,
- FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE SET NULL
- );
- CREATE TABLE IF NOT EXISTS smtp_credentials (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL UNIQUE,
- username TEXT NOT NULL UNIQUE,
- password_hash TEXT NOT NULL,
- password_secret TEXT NOT NULL DEFAULT '',
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE TABLE IF NOT EXISTS api_tokens (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- name TEXT NOT NULL,
- token_hash TEXT NOT NULL UNIQUE,
- token_prefix TEXT NOT NULL,
- last_used_at TEXT,
- created_at TEXT NOT NULL,
- FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE TABLE IF NOT EXISTS dns_credentials (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- name TEXT NOT NULL,
- provider TEXT NOT NULL,
- zone_name TEXT NOT NULL DEFAULT '',
- default_ttl INTEGER NOT NULL DEFAULT 600,
- credentials_secret TEXT NOT NULL,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE TABLE IF NOT EXISTS app_settings (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL,
- updated_at TEXT NOT NULL
- );
- CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON api_tokens(user_id);
- CREATE INDEX IF NOT EXISTS idx_dns_credentials_user_id ON dns_credentials(user_id);
- `);
- ensureColumn('domains', 'user_id', 'INTEGER');
- ensureColumn('domains', 'dns_credential_id', 'INTEGER');
- ensureColumn('send_events', 'user_id', 'INTEGER');
- ensureColumn('smtp_credentials', 'password_secret', "TEXT NOT NULL DEFAULT ''");
- db.exec(`
- CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
- CREATE INDEX IF NOT EXISTS idx_events_user_id ON send_events(user_id);
- `);
- normalizeDkimPublicKeys();
- return db;
- }
- export function seedAdminUser({ username, password, email }) {
- const normalizedUsername = normalizeUsername(username || 'admin');
- const normalizedEmail = normalizeEmail(email || `${normalizedUsername}@mailhub.local`);
- const existing = getUserByLogin(normalizedUsername) || getUserByLogin(normalizedEmail);
- if (existing) {
- requireDb()
- .prepare('UPDATE users SET role = ?, status = ?, updated_at = ? WHERE id = ?')
- .run('admin', 'active', now(), existing.id);
- return getUser(existing.id);
- }
- return createUser({
- username: normalizedUsername,
- email: normalizedEmail,
- password,
- role: 'admin'
- });
- }
- export function claimLegacyData(userId) {
- requireDb().prepare('UPDATE domains SET user_id = ? WHERE user_id IS NULL').run(userId);
- requireDb().prepare(`
- UPDATE send_events
- SET user_id = COALESCE((SELECT user_id FROM domains WHERE domains.id = send_events.domain_id), ?)
- WHERE user_id IS NULL
- `).run(userId);
- if (!tableExists('smtp_credentials_legacy') || getSmtpCredential(userId)) return;
- const legacy = requireDb().prepare('SELECT * FROM smtp_credentials_legacy WHERE id = 1').get();
- if (!legacy?.username || !legacy?.password_hash) return;
- const createdAt = now();
- requireDb()
- .prepare(`
- INSERT INTO smtp_credentials (user_id, username, password_hash, password_secret, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?)
- `)
- .run(userId, legacy.username, legacy.password_hash, legacy.password_secret || '', createdAt, createdAt);
- }
- export function seedSmtpCredential(userId, username, password) {
- if (!userId || !username || !password || getSmtpCredential(userId)) return null;
- return saveSmtpCredential(userId, { username, password });
- }
- export function createUser({ username, email, password, role = 'user' }) {
- const cleanUsername = normalizeUsername(username);
- const cleanEmail = normalizeEmail(email);
- if (!cleanUsername) throw new Error('用户名格式不正确。');
- if (!cleanEmail) throw new Error('邮箱格式不正确。');
- if (String(password || '').length < 8) throw new Error('密码至少需要 8 位。');
- const createdAt = now();
- const result = requireDb()
- .prepare(`
- INSERT INTO users (username, email, password_hash, role, status, created_at, updated_at)
- VALUES (?, ?, ?, ?, 'active', ?, ?)
- `)
- .run(cleanUsername, cleanEmail, hashPassword(password), role === 'admin' ? 'admin' : 'user', createdAt, createdAt);
- return getUser(result.lastInsertRowid);
- }
- export function authenticateUser(login, password) {
- const user = getUserByLogin(login, { includeHash: true });
- if (!user || user.status !== 'active' || !verifyPassword(password, user.passwordHash)) return null;
- return publicUser(user);
- }
- export function listUsers() {
- return requireDb()
- .prepare('SELECT * FROM users ORDER BY created_at DESC')
- .all()
- .map(publicUser);
- }
- export function getUser(id, { includeHash = false } = {}) {
- const row = requireDb().prepare('SELECT * FROM users WHERE id = ?').get(id);
- return includeHash ? privateUser(row) : publicUser(row);
- }
- export function getUserByLogin(login, { includeHash = false } = {}) {
- const value = String(login || '').trim().toLowerCase();
- if (!value) return null;
- const row = requireDb()
- .prepare('SELECT * FROM users WHERE lower(username) = ? OR lower(email) = ?')
- .get(value, value);
- return includeHash ? privateUser(row) : publicUser(row);
- }
- export function updateUser(id, patch) {
- const current = getUser(id, { includeHash: true });
- if (!current) return null;
- const next = {
- role: patch.role === 'admin' ? 'admin' : current.role,
- status: ['active', 'disabled'].includes(patch.status) ? patch.status : current.status,
- passwordHash: patch.password ? hashPassword(patch.password) : current.passwordHash,
- updatedAt: now()
- };
- requireDb()
- .prepare('UPDATE users SET role = ?, status = ?, password_hash = ?, updated_at = ? WHERE id = ?')
- .run(next.role, next.status, next.passwordHash, next.updatedAt, id);
- return getUser(id);
- }
- export function getAdminUser() {
- const row = requireDb()
- .prepare("SELECT * FROM users WHERE role = 'admin' AND status = 'active' ORDER BY id LIMIT 1")
- .get();
- return publicUser(row);
- }
- export function listDomains(userId) {
- return requireDb()
- .prepare('SELECT * FROM domains WHERE user_id = ? ORDER BY created_at DESC')
- .all(userId)
- .map(publicDomainRow);
- }
- export function getDomain(id, { userId, includePrivate = false } = {}) {
- const row = requireDb()
- .prepare('SELECT * FROM domains WHERE id = ? AND (? IS NULL OR user_id = ?)')
- .get(id, userId ?? null, userId ?? null);
- return includePrivate ? privateDomainRow(row) : publicDomainRow(row);
- }
- export function getDomainByName(domain, { userId, includePrivate = false } = {}) {
- const row = requireDb()
- .prepare('SELECT * FROM domains WHERE domain = ? AND (? IS NULL OR user_id = ?)')
- .get(String(domain || '').toLowerCase(), userId ?? null, userId ?? null);
- return includePrivate ? privateDomainRow(row) : publicDomainRow(row);
- }
- export function createDomain(userId, domain) {
- const createdAt = now();
- const result = requireDb()
- .prepare(`
- INSERT INTO domains (
- user_id, dns_credential_id, domain, selector, verification_token,
- dkim_public, dkim_private, sender_host, sending_ip, spf_extra,
- dmarc_policy, dmarc_rua, status_json, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)
- `)
- .run(
- userId,
- domain.dnsCredentialId || null,
- domain.domain,
- domain.selector,
- domain.verificationToken,
- domain.dkimPublic,
- domain.dkimPrivate,
- domain.senderHost,
- domain.sendingIp,
- domain.spfExtra,
- domain.dmarcPolicy,
- domain.dmarcRua,
- createdAt,
- createdAt
- );
- return getDomain(result.lastInsertRowid, { userId });
- }
- export function updateDomain(id, userId, patch) {
- const current = getDomain(id, { userId, includePrivate: true });
- if (!current) return null;
- const next = {
- selector: patch.selector ?? current.selector,
- dnsCredentialId: patch.dnsCredentialId === undefined ? current.dnsCredentialId : (patch.dnsCredentialId || null),
- senderHost: patch.senderHost ?? current.senderHost,
- sendingIp: patch.sendingIp ?? current.sendingIp,
- spfExtra: patch.spfExtra ?? current.spfExtra,
- dmarcPolicy: patch.dmarcPolicy ?? current.dmarcPolicy,
- dmarcRua: patch.dmarcRua ?? current.dmarcRua,
- updatedAt: now()
- };
- requireDb()
- .prepare(`
- UPDATE domains
- SET selector = ?, dns_credential_id = ?, sender_host = ?, sending_ip = ?, spf_extra = ?,
- dmarc_policy = ?, dmarc_rua = ?, updated_at = ?
- WHERE id = ? AND user_id = ?
- `)
- .run(
- next.selector,
- next.dnsCredentialId,
- next.senderHost,
- next.sendingIp,
- next.spfExtra,
- next.dmarcPolicy,
- next.dmarcRua,
- next.updatedAt,
- id,
- userId
- );
- return getDomain(id, { userId });
- }
- export function updateDkim(id, userId, keys, selector) {
- requireDb()
- .prepare('UPDATE domains SET selector = ?, dkim_public = ?, dkim_private = ?, updated_at = ? WHERE id = ? AND user_id = ?')
- .run(selector, keys.publicKey, keys.privateKey, now(), id, userId);
- return getDomain(id, { userId });
- }
- export function saveDomainStatus(id, userId, status) {
- requireDb()
- .prepare('UPDATE domains SET status_json = ?, updated_at = ? WHERE id = ? AND user_id = ?')
- .run(JSON.stringify(status), now(), id, userId);
- }
- export function deleteDomain(id, userId) {
- const result = requireDb().prepare('DELETE FROM domains WHERE id = ? AND user_id = ?').run(id, userId);
- return result.changes > 0;
- }
- export function logSendEvent(event) {
- const result = requireDb()
- .prepare(`
- INSERT INTO send_events (user_id, domain_id, sender, recipients, subject, status, detail, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- `)
- .run(
- event.userId ?? null,
- event.domainId ?? null,
- event.sender,
- JSON.stringify(event.recipients),
- event.subject,
- event.status,
- event.detail ?? '',
- now()
- );
- return result.lastInsertRowid;
- }
- export function listSendEvents(userId, limit = 30) {
- return requireDb()
- .prepare(`
- SELECT e.*, d.domain
- FROM send_events e
- LEFT JOIN domains d ON d.id = e.domain_id
- WHERE e.user_id = ?
- ORDER BY e.created_at DESC
- LIMIT ?
- `)
- .all(userId, limit)
- .map((row) => ({
- id: row.id,
- userId: row.user_id,
- domainId: row.domain_id,
- domain: row.domain,
- sender: row.sender,
- recipients: safeJson(row.recipients, []),
- subject: row.subject,
- status: row.status,
- detail: row.detail,
- createdAt: row.created_at
- }));
- }
- export function getSendAnalytics(userId, { days = 30 } = {}) {
- const windowDays = clampAnalyticsDays(days);
- const since = new Date();
- since.setUTCHours(0, 0, 0, 0);
- since.setUTCDate(since.getUTCDate() - (windowDays - 1));
- const rows = requireDb()
- .prepare(`
- SELECT e.*, d.domain
- FROM send_events e
- LEFT JOIN domains d ON d.id = e.domain_id
- WHERE e.user_id = ? AND e.created_at >= ?
- ORDER BY e.created_at ASC
- LIMIT 5000
- `)
- .all(userId, since.toISOString());
- const domains = listDomains(userId);
- const dayBuckets = buildDayBuckets(windowDays);
- const byStatus = {};
- const byDomain = new Map();
- const hourly = Array.from({ length: 24 }, (_, hour) => ({
- hour,
- total: 0,
- queued: 0,
- failed: 0
- }));
- let recipients = 0;
- let queued = 0;
- let failed = 0;
- let today = 0;
- let last7Days = 0;
- const todayKey = new Date().toISOString().slice(0, 10);
- const weekStart = new Date();
- weekStart.setUTCHours(0, 0, 0, 0);
- weekStart.setUTCDate(weekStart.getUTCDate() - 6);
- for (const row of rows) {
- const status = row.status || 'unknown';
- const recipientList = safeJson(row.recipients, []);
- const recipientCount = Array.isArray(recipientList) ? recipientList.length : 0;
- const domainName = row.domain || (String(row.sender || '').split('@')[1] || 'unknown');
- const createdAt = new Date(row.created_at);
- const dayKey = row.created_at.slice(0, 10);
- const hour = Number.isInteger(createdAt.getUTCHours()) ? createdAt.getUTCHours() : 0;
- const isQueued = status === 'queued';
- recipients += recipientCount;
- queued += isQueued ? 1 : 0;
- failed += isQueued ? 0 : 1;
- today += dayKey === todayKey ? 1 : 0;
- last7Days += createdAt >= weekStart ? 1 : 0;
- byStatus[status] = (byStatus[status] || 0) + 1;
- if (dayBuckets.has(dayKey)) {
- const bucket = dayBuckets.get(dayKey);
- bucket.total += 1;
- bucket.queued += isQueued ? 1 : 0;
- bucket.failed += isQueued ? 0 : 1;
- bucket.recipients += recipientCount;
- }
- const domainBucket = byDomain.get(domainName) || {
- domain: domainName,
- total: 0,
- queued: 0,
- failed: 0,
- recipients: 0
- };
- domainBucket.total += 1;
- domainBucket.queued += isQueued ? 1 : 0;
- domainBucket.failed += isQueued ? 0 : 1;
- domainBucket.recipients += recipientCount;
- byDomain.set(domainName, domainBucket);
- hourly[hour].total += 1;
- hourly[hour].queued += isQueued ? 1 : 0;
- hourly[hour].failed += isQueued ? 0 : 1;
- }
- const recentFailures = [...rows]
- .reverse()
- .filter((row) => row.status !== 'queued')
- .slice(0, 8)
- .map((row) => ({
- id: row.id,
- domain: row.domain || '',
- sender: row.sender,
- subject: row.subject,
- detail: row.detail,
- createdAt: row.created_at
- }));
- return {
- windowDays,
- summary: {
- total: rows.length,
- queued,
- failed,
- recipients,
- today,
- last7Days,
- successRate: rows.length ? Math.round((queued / rows.length) * 1000) / 10 : 0,
- domains: domains.length,
- verifiedDomains: domains.filter((domain) => domain.status?.verified).length
- },
- byDay: [...dayBuckets.values()],
- byDomain: [...byDomain.values()].sort((a, b) => b.total - a.total).slice(0, 10),
- byStatus: Object.entries(byStatus).map(([status, total]) => ({ status, total })),
- hourly,
- recentFailures
- };
- }
- export function getSmtpCredential(userId, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
- const row = requireDb()
- .prepare('SELECT * FROM smtp_credentials WHERE user_id = ?')
- .get(userId);
- if (!row) return null;
- return publicSmtpCredential(row, { includeHash, includePassword, includeSecret });
- }
- export function saveSmtpCredential(userId, { username, password }) {
- const current = getSmtpCredential(userId, { includeHash: true, includeSecret: true });
- const nextUsername = String(username || current?.username || '').trim();
- if (!nextUsername) throw new Error('SMTP 用户名不能为空。');
- const nextHash = password ? hashPassword(password) : current?.passwordHash;
- if (!nextHash) throw new Error('SMTP 密码不能为空。');
- const nextSecret = password ? encryptSecret(password) : current?.passwordSecret || '';
- const updatedAt = now();
- if (current) {
- requireDb()
- .prepare('UPDATE smtp_credentials SET username = ?, password_hash = ?, password_secret = ?, updated_at = ? WHERE user_id = ?')
- .run(nextUsername, nextHash, nextSecret, updatedAt, userId);
- } else {
- requireDb()
- .prepare(`
- INSERT INTO smtp_credentials (user_id, username, password_hash, password_secret, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?)
- `)
- .run(userId, nextUsername, nextHash, nextSecret, updatedAt, updatedAt);
- }
- return getSmtpCredential(userId);
- }
- export function verifySmtpCredential(username, password) {
- const row = requireDb()
- .prepare(`
- SELECT c.*, u.id AS auth_user_id, u.username AS auth_username, u.email, u.role, u.status
- FROM smtp_credentials c
- JOIN users u ON u.id = c.user_id
- WHERE c.username = ?
- `)
- .get(String(username || '').trim());
- if (!row || row.status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
- return {
- user: {
- id: row.auth_user_id,
- username: row.auth_username,
- email: row.email,
- role: row.role,
- status: row.status
- },
- credential: publicSmtpCredential(row)
- };
- }
- export function createApiToken(userId, name) {
- const token = `mh_${crypto.randomBytes(32).toString('base64url')}`;
- const createdAt = now();
- const result = requireDb()
- .prepare(`
- INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, created_at)
- VALUES (?, ?, ?, ?, ?)
- `)
- .run(userId, String(name || 'API Token').trim() || 'API Token', tokenHash(token), token.slice(0, 12), createdAt);
- return {
- ...getApiToken(result.lastInsertRowid, userId),
- token
- };
- }
- export function listApiTokens(userId) {
- return requireDb()
- .prepare('SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC')
- .all(userId)
- .map(publicApiToken);
- }
- export function getApiToken(id, userId) {
- const row = requireDb()
- .prepare('SELECT * FROM api_tokens WHERE id = ? AND user_id = ?')
- .get(id, userId);
- return publicApiToken(row);
- }
- export function deleteApiToken(id, userId) {
- const result = requireDb().prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(id, userId);
- return result.changes > 0;
- }
- export function verifyApiToken(token) {
- const hash = tokenHash(token);
- const row = requireDb()
- .prepare(`
- SELECT t.*, u.id AS auth_user_id, u.username, u.email, u.role, u.status
- FROM api_tokens t
- JOIN users u ON u.id = t.user_id
- WHERE t.token_hash = ?
- `)
- .get(hash);
- if (!row || row.status !== 'active') return null;
- requireDb().prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id);
- return {
- id: row.auth_user_id,
- username: row.username,
- email: row.email,
- role: row.role,
- status: row.status
- };
- }
- export function listDnsCredentials(userId) {
- return requireDb()
- .prepare('SELECT * FROM dns_credentials WHERE user_id = ? ORDER BY created_at DESC')
- .all(userId)
- .map(publicDnsCredential);
- }
- export function getDnsCredential(id, userId, { includeCredentials = false } = {}) {
- const row = requireDb()
- .prepare('SELECT * FROM dns_credentials WHERE id = ? AND user_id = ?')
- .get(id, userId);
- if (!row) return null;
- const publicRow = publicDnsCredential(row);
- if (!includeCredentials) return publicRow;
- return {
- ...publicRow,
- credentials: safeJson(decryptSecret(row.credentials_secret), {})
- };
- }
- export function saveDnsCredential(userId, credential) {
- const provider = normalizeProvider(credential.provider);
- if (!provider) throw new Error('DNS 服务商不支持。');
- const name = String(credential.name || provider).trim();
- const zoneName = String(credential.zoneName || credential.zone || '').trim().toLowerCase();
- const defaultTtl = clampTtl(credential.defaultTtl);
- const credentials = credential.credentials || pickCredentialFields(credential);
- const updatedAt = now();
- if (credential.id) {
- const current = getDnsCredential(credential.id, userId, { includeCredentials: true });
- if (!current) return null;
- const nextCredentials = Object.keys(credentials).length ? credentials : current.credentials;
- requireDb()
- .prepare(`
- UPDATE dns_credentials
- SET name = ?, provider = ?, zone_name = ?, default_ttl = ?, credentials_secret = ?, updated_at = ?
- WHERE id = ? AND user_id = ?
- `)
- .run(name, provider, zoneName, defaultTtl, encryptSecret(JSON.stringify(nextCredentials)), updatedAt, credential.id, userId);
- return getDnsCredential(credential.id, userId);
- }
- const result = requireDb()
- .prepare(`
- INSERT INTO dns_credentials (user_id, name, provider, zone_name, default_ttl, credentials_secret, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- `)
- .run(userId, name, provider, zoneName, defaultTtl, encryptSecret(JSON.stringify(credentials)), updatedAt, updatedAt);
- return getDnsCredential(result.lastInsertRowid, userId);
- }
- export function deleteDnsCredential(id, userId) {
- requireDb().prepare('UPDATE domains SET dns_credential_id = NULL WHERE dns_credential_id = ? AND user_id = ?').run(id, userId);
- const result = requireDb().prepare('DELETE FROM dns_credentials WHERE id = ? AND user_id = ?').run(id, userId);
- return result.changes > 0;
- }
- export function getSettings(defaults = {}) {
- const rows = requireDb().prepare('SELECT * FROM app_settings').all();
- const values = { ...defaults };
- for (const row of rows) values[row.key] = row.value;
- return values;
- }
- export function saveSettings(patch) {
- const allowed = new Set([
- 'appBaseUrl',
- 'mailHostname',
- 'sendingIp',
- 'defaultSpfMechanisms',
- 'dmarcPolicy',
- 'dmarcRua',
- 'sendRequiresVerified'
- ]);
- const updatedAt = now();
- for (const [key, value] of Object.entries(patch)) {
- if (!allowed.has(key)) continue;
- requireDb()
- .prepare(`
- INSERT INTO app_settings (key, value, updated_at)
- VALUES (?, ?, ?)
- ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
- `)
- .run(key, String(value ?? ''), updatedAt);
- }
- return getSettings();
- }
- function migrateLegacySmtpTable() {
- if (!tableExists('smtp_credentials') || columnExists('smtp_credentials', 'user_id')) return;
- if (!tableExists('smtp_credentials_legacy')) {
- requireDb().exec('ALTER TABLE smtp_credentials RENAME TO smtp_credentials_legacy;');
- } else {
- requireDb().exec('DROP TABLE smtp_credentials;');
- }
- }
- function requireDb() {
- if (!db) throw new Error('Database is not initialized.');
- return db;
- }
- function tableExists(table) {
- return Boolean(requireDb().prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table));
- }
- function columnExists(table, column) {
- if (!tableExists(table)) return false;
- return requireDb()
- .prepare(`PRAGMA table_info(${table})`)
- .all()
- .some((row) => row.name === column);
- }
- function ensureColumn(table, column, definition) {
- if (!columnExists(table, column)) requireDb().exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
- }
- 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();
- const update = requireDb().prepare('UPDATE domains SET dkim_public = ?, updated_at = ? WHERE id = ?');
- for (const row of rows) {
- if (!row.dkim_private) continue;
- try {
- const publicKey = dkimPublicFromPrivateKey(row.dkim_private);
- if (publicKey && publicKey !== row.dkim_public) update.run(publicKey, now(), row.id);
- } catch {
- // Leave legacy or malformed rows untouched; rotating DKIM from the UI can repair them.
- }
- }
- }
- function publicUser(row) {
- if (!row) return null;
- return {
- id: row.id,
- username: row.username,
- email: row.email,
- role: row.role,
- status: row.status,
- createdAt: row.created_at,
- updatedAt: row.updated_at
- };
- }
- function privateUser(row) {
- const user = publicUser(row);
- return user ? { ...user, passwordHash: row.password_hash } : null;
- }
- function publicDomainRow(row) {
- if (!row) return null;
- return {
- id: row.id,
- userId: row.user_id,
- dnsCredentialId: row.dns_credential_id,
- domain: row.domain,
- selector: row.selector,
- verificationToken: row.verification_token,
- dkimPublic: row.dkim_public,
- senderHost: row.sender_host,
- sendingIp: row.sending_ip,
- spfExtra: row.spf_extra,
- dmarcPolicy: row.dmarc_policy,
- dmarcRua: row.dmarc_rua,
- status: safeJson(row.status_json, {}),
- createdAt: row.created_at,
- updatedAt: row.updated_at
- };
- }
- function privateDomainRow(row) {
- const publicRow = publicDomainRow(row);
- return publicRow ? { ...publicRow, dkimPrivate: row.dkim_private } : null;
- }
- function publicSmtpCredential(row, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
- if (!row) return null;
- const password = includePassword ? decryptSecret(row.password_secret) : '';
- const passwordRecoverable = Boolean(row.password_secret && (password || decryptSecret(row.password_secret)));
- return {
- id: row.id,
- userId: row.user_id,
- username: row.username,
- passwordSet: Boolean(row.password_hash),
- passwordRecoverable,
- ...(includePassword ? { password } : {}),
- ...(includeHash ? { passwordHash: row.password_hash } : {}),
- ...(includeSecret ? { passwordSecret: row.password_secret } : {}),
- createdAt: row.created_at,
- updatedAt: row.updated_at
- };
- }
- function publicApiToken(row) {
- if (!row) return null;
- return {
- id: row.id,
- userId: row.user_id,
- name: row.name,
- tokenPrefix: row.token_prefix,
- lastUsedAt: row.last_used_at,
- createdAt: row.created_at
- };
- }
- function publicDnsCredential(row) {
- if (!row) return null;
- return {
- id: row.id,
- userId: row.user_id,
- name: row.name,
- provider: row.provider,
- zoneName: row.zone_name,
- defaultTtl: row.default_ttl,
- credentialSet: Boolean(row.credentials_secret),
- createdAt: row.created_at,
- updatedAt: row.updated_at
- };
- }
- function normalizeUsername(value) {
- const username = String(value || '').trim().toLowerCase();
- return /^[a-z0-9][a-z0-9_.-]{2,31}$/.test(username) ? username : '';
- }
- function normalizeEmail(value) {
- const email = String(value || '').trim().toLowerCase();
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : '';
- }
- function normalizeProvider(value) {
- const provider = String(value || '').trim().toLowerCase();
- return ['cloudflare', 'aliyun', 'dnspod'].includes(provider) ? provider : '';
- }
- function pickCredentialFields(source) {
- const output = {};
- for (const key of ['apiToken', 'zoneId', 'accessKeyId', 'accessKeySecret', 'secretId', 'secretKey']) {
- if (source[key]) output[key] = String(source[key]).trim();
- }
- return output;
- }
- function clampTtl(value) {
- const ttl = Number(value || 600);
- if (!Number.isInteger(ttl) || ttl < 60) return 600;
- if (ttl > 86400) return 86400;
- return ttl;
- }
- function clampAnalyticsDays(value) {
- const days = Number(value || 30);
- if (!Number.isInteger(days) || days < 7) return 30;
- if (days > 90) return 90;
- return days;
- }
- function buildDayBuckets(days) {
- const buckets = new Map();
- const cursor = new Date();
- cursor.setUTCHours(0, 0, 0, 0);
- cursor.setUTCDate(cursor.getUTCDate() - (days - 1));
- for (let index = 0; index < days; index += 1) {
- const date = new Date(cursor);
- date.setUTCDate(cursor.getUTCDate() + index);
- const day = date.toISOString().slice(0, 10);
- buckets.set(day, {
- day,
- total: 0,
- queued: 0,
- failed: 0,
- recipients: 0
- });
- }
- return buckets;
- }
- function safeJson(value, fallback) {
- try {
- return JSON.parse(value);
- } catch {
- return fallback;
- }
- }
- function now() {
- return new Date().toISOString();
- }
- 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);
- const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv);
- const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
- return [
- 'v1',
- iv.toString('base64url'),
- cipher.getAuthTag().toString('base64url'),
- encrypted.toString('base64url')
- ].join('$');
- }
- function decryptSecret(secret) {
- const [version, ivRaw, tagRaw, encryptedRaw] = String(secret || '').split('$');
- if (version !== 'v1' || !ivRaw || !tagRaw || !encryptedRaw) return '';
- try {
- const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivRaw, 'base64url'));
- decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
- return Buffer.concat([
- decipher.update(Buffer.from(encryptedRaw, 'base64url')),
- decipher.final()
- ]).toString('utf8');
- } catch {
- return '';
- }
- }
- function encryptionKey() {
- return crypto
- .createHash('sha256')
- .update(secretKey || 'mailhub-local-secret')
- .digest();
- }
- function safeEqual(actual, expected) {
- const a = Buffer.from(String(actual || ''));
- const b = Buffer.from(String(expected || ''));
- if (a.length !== b.length) return false;
- return crypto.timingSafeEqual(a, b);
- }
|