| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730 |
- 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 const USER_STATUSES = new Set(['pending_email', 'pending_review', 'active', 'disabled']);
- const auditSecretKeyPattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
- const auditDescriptorKeyPattern = /^(field|name|path|key|header)$/i;
- const auditDescriptorValuePattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
- const auditDescriptorWrapperKeyPattern = /^(change|context|descriptor|meta)$/i;
- const auditValueLikeKeyPattern = /^(value|from|to|old|new|old_?value|new_?value|before|after)$/i;
- const maxAccountTokenTtlMinutes = 7 * 24 * 60;
- 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 '',
- queue_id TEXT NOT NULL DEFAULT '',
- delivery_log_json TEXT NOT NULL DEFAULT '[]',
- delivery_attempts_json TEXT NOT NULL DEFAULT '[]',
- delivered_at TEXT,
- created_at TEXT NOT NULL,
- FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE SET NULL
- );
- CREATE TABLE IF NOT EXISTS audit_logs (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- actor_user_id INTEGER,
- action TEXT NOT NULL,
- target_type TEXT NOT NULL,
- target_id TEXT NOT NULL DEFAULT '',
- target_user_id INTEGER,
- summary_json TEXT NOT NULL DEFAULT '{}',
- created_at TEXT NOT 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 account_tokens (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- purpose TEXT NOT NULL,
- token_hash TEXT NOT NULL UNIQUE,
- expires_at TEXT NOT NULL,
- 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_account_tokens_user_purpose ON account_tokens(user_id, purpose);
- CREATE INDEX IF NOT EXISTS idx_account_tokens_expires_at ON account_tokens(expires_at);
- CREATE INDEX IF NOT EXISTS idx_dns_credentials_user_id ON dns_credentials(user_id);
- CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
- CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_user_id ON audit_logs(actor_user_id);
- CREATE INDEX IF NOT EXISTS idx_audit_logs_target_user_id ON audit_logs(target_user_id);
- CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
- `);
- ensureColumn('domains', 'user_id', 'INTEGER');
- ensureColumn('domains', 'dns_credential_id', 'INTEGER');
- ensureColumn('send_events', 'user_id', 'INTEGER');
- ensureColumn('send_events', 'queue_id', "TEXT NOT NULL DEFAULT ''");
- ensureColumn('send_events', 'delivery_log_json', "TEXT NOT NULL DEFAULT '[]'");
- ensureColumn('send_events', 'delivery_attempts_json', "TEXT NOT NULL DEFAULT '[]'");
- ensureColumn('send_events', 'delivered_at', 'TEXT');
- 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);
- CREATE INDEX IF NOT EXISTS idx_events_queue_id ON send_events(queue_id);
- `);
- normalizeSendEventQueueIds();
- 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',
- status: 'active'
- });
- }
- 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', status = 'active' }) {
- const cleanUsername = normalizeUsername(username);
- const cleanEmail = normalizeEmail(email);
- const cleanStatus = normalizeUserStatus(status);
- 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 (?, ?, ?, ?, ?, ?, ?)
- `)
- .run(cleanUsername, cleanEmail, hashPassword(password), role === 'admin' ? 'admin' : 'user', cleanStatus, createdAt, createdAt);
- return getUser(result.lastInsertRowid);
- }
- export function createUserWithAccountToken(userInput, tokenPurpose, { ttlMinutes } = {}) {
- const database = requireDb();
- database.exec('BEGIN');
- try {
- const user = createUser(userInput);
- const accountToken = createAccountToken(user.id, tokenPurpose, { ttlMinutes });
- database.exec('COMMIT');
- return { user, accountToken };
- } catch (error) {
- database.exec('ROLLBACK');
- throw error;
- }
- }
- export function authenticateUser(login, password) {
- const user = verifyUserCredentials(login, password);
- return user?.status === 'active' ? user : null;
- }
- export function verifyUserCredentials(login, password) {
- const user = getUserByLogin(login, { includeHash: true });
- if (!user || !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 listUsersWithResourceCounts() {
- return requireDb()
- .prepare(`
- SELECT
- users.*,
- (SELECT COUNT(*) FROM domains WHERE domains.user_id = users.id) AS domains_count,
- (SELECT COUNT(*) FROM dns_credentials WHERE dns_credentials.user_id = users.id) AS dns_credentials_count,
- (SELECT COUNT(*) FROM api_tokens WHERE api_tokens.user_id = users.id) AS api_tokens_count,
- (SELECT COUNT(*) FROM send_events WHERE send_events.user_id = users.id) AS send_events_count,
- (SELECT COUNT(*) FROM smtp_credentials WHERE smtp_credentials.user_id = users.id) AS smtp_credentials_count
- FROM users
- ORDER BY users.created_at DESC
- `)
- .all()
- .map((row) => ({
- ...publicUser(row),
- resourceCounts: {
- domains: Number(row.domains_count || 0),
- dnsCredentials: Number(row.dns_credentials_count || 0),
- apiTokens: Number(row.api_tokens_count || 0),
- sendEvents: Number(row.send_events_count || 0),
- smtpCredential: Number(row.smtp_credentials_count || 0) > 0 ? 1 : 0
- }
- }));
- }
- export function getAdminResourceInventory() {
- const users = listUsersWithResourceCounts();
- const domains = requireDb()
- .prepare('SELECT * FROM domains ORDER BY user_id, created_at DESC')
- .all()
- .map(publicDomainRow);
- const dnsCredentials = requireDb()
- .prepare('SELECT * FROM dns_credentials ORDER BY user_id, created_at DESC')
- .all()
- .map(publicDnsCredential);
- const smtpCredentials = requireDb()
- .prepare('SELECT * FROM smtp_credentials ORDER BY user_id')
- .all()
- .map(publicSmtpCredential);
- const apiTokens = requireDb()
- .prepare('SELECT * FROM api_tokens ORDER BY user_id, created_at DESC')
- .all()
- .map(publicApiToken);
- const sendEventCounts = new Map(
- requireDb()
- .prepare('SELECT user_id, COUNT(*) AS count FROM send_events GROUP BY user_id')
- .all()
- .map((row) => [row.user_id, Number(row.count || 0)])
- );
- const dnsCredentialById = new Map(dnsCredentials.map((credential) => [credential.id, credential]));
- return {
- users: users.map((user) => ({
- user,
- domains: domains.filter((domain) => domain.userId === user.id),
- dnsCredentials: dnsCredentials.filter((credential) => credential.userId === user.id),
- smtpCredential: smtpCredentials.find((credential) => credential.userId === user.id) || null,
- apiTokens: apiTokens.filter((token) => token.userId === user.id),
- sendEventCount: sendEventCounts.get(user.id) || 0
- })),
- warnings: domains.flatMap((domain) => {
- if (!domain.dnsCredentialId) return [];
- const credential = dnsCredentialById.get(domain.dnsCredentialId);
- if (!credential || credential.userId === domain.userId) return [];
- return [{
- type: 'domain_dns_credential_owner_mismatch',
- domainId: domain.id,
- domain: domain.domain,
- domainUserId: domain.userId,
- dnsCredentialId: credential.id,
- dnsCredentialUserId: credential.userId
- }];
- })
- };
- }
- export function transferDomain({ actorUserId, domainId, targetUserId, dnsCredentialMode = 'domain_only' }) {
- return withTransaction(() => {
- const target = requireTransferTargetUser(targetUserId);
- const domain = requireDomainRow(domainId);
- const mode = normalizeDnsCredentialTransferMode(dnsCredentialMode);
- const nextDnsCredentialId = mode === 'clear_dns_credential' ? null : domain.dns_credential_id;
- requireDb()
- .prepare('UPDATE domains SET user_id = ?, dns_credential_id = ?, updated_at = ? WHERE id = ?')
- .run(target.id, nextDnsCredentialId, now(), domain.id);
- if (mode === 'with_dns_credential' && domain.dns_credential_id) {
- const credential = requireDnsCredentialRow(domain.dns_credential_id);
- if (credential.user_id !== domain.user_id) throw new Error('DNS 凭据归属不一致。');
- requireDb()
- .prepare('UPDATE dns_credentials SET user_id = ?, updated_at = ? WHERE id = ?')
- .run(target.id, now(), domain.dns_credential_id);
- }
- const updated = getDomain(domain.id);
- logAudit({
- actorUserId,
- action: 'admin.transfer_domain',
- targetType: 'domain',
- targetId: String(domain.id),
- targetUserId: target.id,
- summary: {
- domain: domain.domain,
- fromUserId: domain.user_id,
- toUserId: target.id,
- dnsCredentialMode: mode,
- dnsCredentialId: domain.dns_credential_id || null
- }
- });
- return updated;
- });
- }
- export function transferDnsCredential({ actorUserId, credentialId, targetUserId }) {
- return withTransaction(() => {
- const target = requireTransferTargetUser(targetUserId);
- const credential = requireDnsCredentialRow(credentialId);
- requireDb()
- .prepare('UPDATE dns_credentials SET user_id = ?, updated_at = ? WHERE id = ?')
- .run(target.id, now(), credential.id);
- const updated = publicDnsCredential(requireDnsCredentialRow(credential.id));
- logAudit({
- actorUserId,
- action: 'admin.transfer_dns_credential',
- targetType: 'dns_credential',
- targetId: String(credential.id),
- targetUserId: target.id,
- summary: {
- name: credential.name,
- provider: credential.provider,
- fromUserId: credential.user_id,
- toUserId: target.id
- }
- });
- return updated;
- });
- }
- export function transferApiTokens({ actorUserId, tokenIds, targetUserId }) {
- return withTransaction(() => {
- const target = requireTransferTargetUser(targetUserId);
- const ids = uniquePositiveIds(tokenIds);
- if (!ids.length) throw new Error('API Token 不存在。');
- const placeholders = ids.map(() => '?').join(', ');
- const tokens = requireDb()
- .prepare(`SELECT * FROM api_tokens WHERE id IN (${placeholders})`)
- .all(...ids);
- if (tokens.length !== ids.length) throw new Error('API Token 不存在。');
- requireDb()
- .prepare(`UPDATE api_tokens SET user_id = ? WHERE id IN (${placeholders})`)
- .run(target.id, ...ids);
- const updated = requireDb()
- .prepare(`SELECT * FROM api_tokens WHERE id IN (${placeholders}) ORDER BY created_at DESC`)
- .all(...ids)
- .map(publicApiToken);
- logAudit({
- actorUserId,
- action: 'admin.transfer_api_tokens',
- targetType: 'api_token',
- targetId: ids.join(','),
- targetUserId: target.id,
- summary: {
- tokenIds: ids,
- count: ids.length,
- fromUserIds: [...new Set(tokens.map((token) => token.user_id))],
- toUserId: target.id
- }
- });
- return updated;
- });
- }
- export function previewUserMerge({ sourceUserId, targetUserId }) {
- const { source, target } = requireMergeUsers(sourceUserId, targetUserId);
- const sourceSmtp = getSmtpCredential(source.id);
- const targetSmtp = getSmtpCredential(target.id);
- const counts = {
- domains: countRows('domains', source.id),
- dnsCredentials: countRows('dns_credentials', source.id),
- apiTokens: countRows('api_tokens', source.id),
- sendEvents: countRows('send_events', source.id),
- smtpCredential: sourceSmtp ? 1 : 0
- };
- const smtpConflict = Boolean(sourceSmtp && targetSmtp);
- const defaultOptions = {
- transferDomains: true,
- transferDnsCredentials: true,
- transferApiTokens: true,
- transferSendEvents: true,
- transferSmtpCredential: Boolean(sourceSmtp && !targetSmtp),
- disableSource: true
- };
- const selectedCounts = {
- domains: counts.domains,
- dnsCredentials: counts.dnsCredentials,
- apiTokens: counts.apiTokens,
- sendEvents: counts.sendEvents,
- smtpCredential: defaultOptions.transferSmtpCredential ? counts.smtpCredential : 0
- };
- return {
- sourceUser: source,
- targetUser: target,
- confirmationText: `MERGE ${source.username} INTO ${target.username}`,
- counts,
- selectedCounts,
- defaultOptions,
- resources: {
- source: mergeResourcesForUser(source.id),
- target: mergeResourcesForUser(target.id)
- },
- smtp: {
- sourceCredential: sourceSmtp,
- targetCredential: targetSmtp,
- conflict: smtpConflict
- },
- warnings: smtpConflict ? [{
- type: 'smtp_credential_conflict',
- message: '目标用户已有 SMTP 凭据,源用户 SMTP 凭据需要手动处理。'
- }] : []
- };
- }
- export function executeUserMerge({ actorUserId, sourceUserId, targetUserId, options = {}, confirmation }) {
- return withTransaction(() => {
- const preview = previewUserMerge({ sourceUserId, targetUserId });
- if (confirmation !== preview.confirmationText) throw new Error('确认文本不匹配。');
- const sourceId = preview.sourceUser.id;
- const targetId = preview.targetUser.id;
- const counts = {
- domains: options.transferDomains === false ? 0 : moveRows('domains', sourceId, targetId),
- dnsCredentials: options.transferDnsCredentials === false ? 0 : moveRows('dns_credentials', sourceId, targetId),
- apiTokens: options.transferApiTokens === false ? 0 : moveRows('api_tokens', sourceId, targetId),
- sendEvents: options.transferSendEvents === false ? 0 : moveRows('send_events', sourceId, targetId),
- smtpCredential: 0
- };
- if (options.transferSmtpCredential !== false && preview.smtp.sourceCredential && !preview.smtp.targetCredential) {
- counts.smtpCredential = moveRows('smtp_credentials', sourceId, targetId);
- }
- if (options.disableSource !== false) {
- requireDb()
- .prepare("UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?")
- .run(now(), sourceId);
- }
- logAudit({
- actorUserId,
- action: 'admin.user_merge',
- targetType: 'user',
- targetId: String(targetId),
- targetUserId: targetId,
- summary: {
- sourceUserId: sourceId,
- sourceUsername: preview.sourceUser.username,
- targetUserId: targetId,
- targetUsername: preview.targetUser.username,
- counts,
- warnings: preview.warnings
- }
- });
- return {
- sourceUser: getUser(sourceId),
- targetUser: getUser(targetId),
- counts,
- warnings: preview.warnings
- };
- });
- }
- 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 passwordChanged = String(patch.password || '').length > 0;
- if (passwordChanged && String(patch.password).length < 8) throw new Error('密码至少需要 8 位。');
- const next = {
- role: patch.role === 'admin' ? 'admin' : current.role,
- status: patch.status === undefined ? current.status : normalizeUserStatus(patch.status),
- passwordHash: passwordChanged ? 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);
- if (passwordChanged) invalidateAccountTokens(id, 'password_reset');
- return getUser(id);
- }
- export function updateUserStatus(id, status) {
- const nextStatus = normalizeUserStatus(status);
- if (!getUser(id)) return null;
- requireDb()
- .prepare('UPDATE users SET status = ?, updated_at = ? WHERE id = ?')
- .run(nextStatus, now(), id);
- return getUser(id);
- }
- export function approveUser(id) {
- return updateUserStatus(id, 'active');
- }
- export function markUserEmailVerified(id) {
- const user = getUser(id);
- if (!user) return null;
- if (user.status !== 'pending_email') return user;
- return updateUserStatus(id, 'pending_review');
- }
- 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 logAudit({ actorUserId, action, targetType, targetId = '', targetUserId = null, summary = {} }) {
- const result = requireDb()
- .prepare(`
- INSERT INTO audit_logs (
- actor_user_id, action, target_type, target_id, target_user_id, summary_json, created_at
- )
- VALUES (?, ?, ?, ?, ?, ?, ?)
- `)
- .run(
- actorUserId ?? null,
- String(action || ''),
- String(targetType || ''),
- String(targetId ?? ''),
- targetUserId ?? null,
- JSON.stringify(sanitizeAuditSummary(summary)),
- now()
- );
- return result.lastInsertRowid;
- }
- export function listAuditLogs(filters = {}) {
- const where = [];
- const params = [];
- addAuditFilter(where, params, 'actor_user_id', filters.actorUserId);
- addAuditFilter(where, params, 'target_user_id', filters.targetUserId);
- addAuditFilter(where, params, 'action', filters.action);
- addAuditDateFilter(where, params, 'created_at', '>=', filters.from);
- addAuditDateFilter(where, params, 'created_at', '<=', filters.to);
- const query = `
- SELECT *
- FROM audit_logs
- ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
- ORDER BY created_at DESC, id DESC
- `;
- return requireDb().prepare(query).all(...params).map(publicAuditLog);
- }
- export function logSendEvent(event) {
- const queueId = normalizeQueueId(event.queueId || extractQueueIdFromText(event.detail));
- const result = requireDb()
- .prepare(`
- INSERT INTO send_events (
- user_id, domain_id, sender, recipients, subject, status, detail, queue_id,
- delivery_log_json, delivery_attempts_json, delivered_at, created_at
- )
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `)
- .run(
- event.userId ?? null,
- event.domainId ?? null,
- event.sender,
- JSON.stringify(event.recipients),
- event.subject,
- event.status,
- event.detail ?? '',
- queueId,
- JSON.stringify(Array.isArray(event.deliveryLog) ? event.deliveryLog : []),
- JSON.stringify(Array.isArray(event.deliveryAttempts) ? event.deliveryAttempts : []),
- event.deliveredAt ?? null,
- now()
- );
- return result.lastInsertRowid;
- }
- export function updateSendEventDelivery(queueId, attempt) {
- const cleanQueueId = normalizeQueueId(queueId || attempt?.queueId);
- if (!cleanQueueId) return false;
- const row = requireDb().prepare('SELECT * FROM send_events WHERE queue_id = ? ORDER BY id DESC LIMIT 1').get(cleanQueueId);
- if (!row) return false;
- const normalizedAttempt = normalizeDeliveryAttempt(attempt, cleanQueueId);
- const attempts = safeJson(row.delivery_attempts_json, []);
- if (attempts.some((item) => deliveryAttemptKey(item) === deliveryAttemptKey(normalizedAttempt))) return true;
- const nextAttempts = [...attempts, normalizedAttempt];
- const recipients = safeJson(row.recipients, []);
- const nextStatus = deliveryStatusForEvent(recipients, nextAttempts, row.status);
- const deliveredAt = nextStatus === 'sent' ? normalizedAttempt.at : row.delivered_at;
- requireDb()
- .prepare(`
- UPDATE send_events
- SET status = ?, detail = ?, delivery_attempts_json = ?, delivered_at = ?
- WHERE id = ?
- `)
- .run(nextStatus, deliveryAttemptDetail(normalizedAttempt), JSON.stringify(nextAttempts), deliveredAt, row.id);
- return true;
- }
- 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,
- queueId: row.queue_id,
- deliveryLog: safeJson(row.delivery_log_json, []),
- deliveryAttempts: safeJson(row.delivery_attempts_json, []),
- deliveredAt: row.delivered_at,
- 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 = ['queued', 'sent'].includes(status);
- 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) => isDeliveryFailureStatus(row.status))
- .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 createAccountToken(userId, purpose, { ttlMinutes } = {}) {
- const cleanPurpose = normalizeAccountTokenPurpose(purpose);
- const ttl = Number(ttlMinutes);
- if (!Number.isInteger(ttl) || ttl < 1 || ttl > maxAccountTokenTtlMinutes) throw new Error('令牌有效期不正确。');
- const token = crypto.randomBytes(32).toString('base64url');
- const createdAt = now();
- const expiresAt = new Date(Date.now() + ttl * 60 * 1000).toISOString();
- const result = requireDb()
- .prepare(`
- INSERT INTO account_tokens (user_id, purpose, token_hash, expires_at, created_at)
- VALUES (?, ?, ?, ?, ?)
- `)
- .run(userId, cleanPurpose, tokenHash(token), expiresAt, createdAt);
- return {
- ...publicAccountToken(getAccountTokenRow(result.lastInsertRowid)),
- token
- };
- }
- export function consumeAccountToken(token, purpose) {
- const rawToken = String(token || '');
- const cleanPurpose = String(purpose || '').trim();
- if (!rawToken || !cleanPurpose) return null;
- const tokenDigest = tokenHash(rawToken);
- const usedAt = now();
- const result = requireDb()
- .prepare(`
- UPDATE account_tokens
- SET used_at = ?
- WHERE token_hash = ? AND purpose = ? AND used_at IS NULL AND expires_at > ?
- `)
- .run(usedAt, tokenDigest, cleanPurpose, usedAt);
- if (result.changes === 0) return null;
- const row = requireDb()
- .prepare('SELECT * FROM account_tokens WHERE token_hash = ? AND purpose = ?')
- .get(tokenDigest, cleanPurpose);
- return publicAccountToken(row);
- }
- export function invalidateAccountTokens(userId, purpose) {
- const cleanPurpose = String(purpose || '').trim();
- if (!userId || !cleanPurpose) return 0;
- const result = requireDb()
- .prepare('UPDATE account_tokens SET used_at = ? WHERE user_id = ? AND purpose = ? AND used_at IS NULL')
- .run(now(), userId, cleanPurpose);
- return result.changes;
- }
- 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;
- saveAppSetting(key, String(value ?? ''), updatedAt);
- }
- return getSettings();
- }
- export function getSystemEmailSettings({ includeSecret = false } = {}) {
- const rows = requireDb()
- .prepare("SELECT key, value FROM app_settings WHERE key LIKE 'systemEmail.%'")
- .all();
- const values = Object.fromEntries(
- rows.map((row) => [String(row.key).replace(/^systemEmail\./, ''), row.value])
- );
- const passwordSecret = values.passwordSecret || '';
- const settings = {
- host: values.host || '',
- port: normalizePort(values.port, 587),
- secure: values.secure === 'true',
- username: values.username || '',
- helo: values.helo || '',
- fromEmail: values.fromEmail || '',
- fromName: values.fromName || '',
- testRecipient: values.testRecipient || '',
- passwordSet: Boolean(passwordSecret)
- };
- if (includeSecret) settings.password = decryptSecret(passwordSecret);
- return settings;
- }
- export function saveSystemEmailSettings(patch = {}) {
- const current = getSystemEmailSettings({ includeSecret: true });
- const next = {
- host: patch.host ?? current.host,
- port: patch.port ?? current.port,
- secure: patch.secure ?? current.secure,
- username: patch.username ?? current.username,
- helo: patch.helo ?? current.helo,
- fromEmail: patch.fromEmail ?? current.fromEmail,
- fromName: patch.fromName ?? current.fromName,
- testRecipient: patch.testRecipient ?? current.testRecipient
- };
- const passwordSecret = Object.hasOwn(patch, 'password') && String(patch.password || '')
- ? encryptSecret(patch.password)
- : requireDb()
- .prepare("SELECT value FROM app_settings WHERE key = 'systemEmail.passwordSecret'")
- .get()?.value || '';
- const updatedAt = now();
- const values = {
- host: String(next.host || ''),
- port: String(normalizePort(next.port, 587)),
- secure: boolString(next.secure),
- username: String(next.username || ''),
- helo: String(next.helo || ''),
- fromEmail: normalizeEmail(next.fromEmail),
- fromName: String(next.fromName || ''),
- testRecipient: normalizeEmail(next.testRecipient),
- passwordSecret
- };
- for (const [key, value] of Object.entries(values)) {
- saveAppSetting(`systemEmail.${key}`, value, updatedAt);
- }
- return getSystemEmailSettings();
- }
- function saveAppSetting(key, value, updatedAt = now()) {
- 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);
- }
- function withTransaction(callback) {
- const database = requireDb();
- database.exec('BEGIN');
- try {
- const result = callback();
- database.exec('COMMIT');
- return result;
- } catch (error) {
- database.exec('ROLLBACK');
- throw error;
- }
- }
- function requireTransferTargetUser(targetUserId) {
- const target = getUser(Number(targetUserId));
- if (!target || target.status === 'disabled') throw new Error('目标用户不可用。');
- return target;
- }
- function requireMergeUsers(sourceUserId, targetUserId) {
- const source = getUser(Number(sourceUserId));
- const target = requireTransferTargetUser(targetUserId);
- if (!source) throw new Error('源用户不存在。');
- if (source.id === target.id) throw new Error('源用户和目标用户不能相同。');
- return { source, target };
- }
- function mergeResourcesForUser(userId) {
- return {
- domains: listDomains(userId),
- dnsCredentials: listDnsCredentials(userId),
- apiTokens: listApiTokens(userId),
- sendEventCount: countRows('send_events', userId),
- smtpCredential: getSmtpCredential(userId)
- };
- }
- function countRows(table, userId) {
- return Number(requireDb().prepare(`SELECT COUNT(*) AS count FROM ${mergeResourceTable(table)} WHERE user_id = ?`).get(userId).count || 0);
- }
- function moveRows(table, sourceUserId, targetUserId) {
- const result = requireDb()
- .prepare(`UPDATE ${mergeResourceTable(table)} SET user_id = ? WHERE user_id = ?`)
- .run(targetUserId, sourceUserId);
- return result.changes;
- }
- function mergeResourceTable(table) {
- if (!['domains', 'dns_credentials', 'api_tokens', 'send_events', 'smtp_credentials'].includes(table)) {
- throw new Error('资源类型不正确。');
- }
- return table;
- }
- function requireDomainRow(domainId) {
- const domain = requireDb().prepare('SELECT * FROM domains WHERE id = ?').get(Number(domainId));
- if (!domain) throw new Error('域名不存在。');
- return domain;
- }
- function requireDnsCredentialRow(credentialId) {
- const credential = requireDb().prepare('SELECT * FROM dns_credentials WHERE id = ?').get(Number(credentialId));
- if (!credential) throw new Error('DNS 凭据不存在。');
- return credential;
- }
- function normalizeDnsCredentialTransferMode(value) {
- const mode = String(value || 'domain_only').trim();
- return ['domain_only', 'with_dns_credential', 'clear_dns_credential'].includes(mode) ? mode : 'domain_only';
- }
- function uniquePositiveIds(values) {
- return [...new Set((Array.isArray(values) ? values : [values])
- .map((value) => Number(value))
- .filter((value) => Number.isInteger(value) && value > 0))];
- }
- 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 normalizeSendEventQueueIds() {
- if (!tableExists('send_events') || !columnExists('send_events', 'queue_id')) return;
- const rows = requireDb()
- .prepare("SELECT id, detail FROM send_events WHERE queue_id = '' OR queue_id IS NULL")
- .all();
- const update = requireDb().prepare('UPDATE send_events SET queue_id = ? WHERE id = ?');
- for (const row of rows) {
- const queueId = extractQueueIdFromText(row.detail);
- if (queueId) update.run(queueId, row.id);
- }
- }
- 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 getAccountTokenRow(id) {
- return requireDb().prepare('SELECT * FROM account_tokens WHERE id = ?').get(id);
- }
- function publicAccountToken(row) {
- if (!row) return null;
- return {
- id: row.id,
- userId: row.user_id,
- purpose: row.purpose,
- expiresAt: row.expires_at,
- usedAt: row.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 publicAuditLog(row) {
- if (!row) return null;
- return {
- id: row.id,
- actorUserId: row.actor_user_id,
- action: row.action,
- targetType: row.target_type,
- targetId: row.target_id,
- targetUserId: row.target_user_id,
- summary: safeJson(row.summary_json, {}),
- createdAt: row.created_at
- };
- }
- function normalizeUsername(value) {
- const username = String(value || '').trim().toLowerCase();
- return /^[a-z0-9][a-z0-9_.-]{2,31}$/.test(username) ? username : '';
- }
- function normalizeUserStatus(value) {
- const status = String(value || '').trim();
- if (!USER_STATUSES.has(status)) throw new Error('用户状态不正确。');
- return status;
- }
- function normalizeAccountTokenPurpose(value) {
- const purpose = String(value || '').trim();
- if (!purpose) throw new Error('账号令牌用途不能为空。');
- return purpose;
- }
- function normalizeEmail(value) {
- const email = String(value || '').trim().toLowerCase();
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : '';
- }
- function normalizePort(value, fallback) {
- const port = Number(value);
- return Number.isInteger(port) && port > 0 && port <= 65535 ? port : fallback;
- }
- function boolString(value) {
- return value === true || String(value).toLowerCase() === 'true' ? 'true' : 'false';
- }
- 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 sanitizeAuditSummary(value, parentKey = '') {
- if (Array.isArray(value)) return value.map((item) => sanitizeAuditSummary(item, parentKey));
- if (!value || typeof value !== 'object') return value;
- const hasSensitiveDescriptor = hasSensitiveAuditDescriptor(value);
- const output = {};
- for (const [key, child] of Object.entries(value)) {
- if (auditSecretKeyPattern.test(key) && !isSafeAuditStateKey(key, child, parentKey)) continue;
- if (hasSensitiveDescriptor && auditValueLikeKeyPattern.test(key)) continue;
- output[key] = sanitizeAuditSummary(child, key);
- }
- return output;
- }
- function isSafeAuditStateKey(key, value, parentKey) {
- return (key === 'passwordSet' && typeof value === 'boolean') ||
- (parentKey === 'counts' && typeof value === 'number');
- }
- function hasSensitiveAuditDescriptor(value) {
- return Object.entries(value).some(([key, child]) => (
- auditDescriptorKeyPattern.test(key) && isSensitiveAuditDescriptorValue(child)
- )) || Object.entries(value).some(([key, child]) => (
- auditDescriptorWrapperKeyPattern.test(key) && hasDirectSensitiveAuditDescriptor(child)
- ));
- }
- function hasDirectSensitiveAuditDescriptor(value) {
- if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
- return Object.entries(value).some(([key, child]) => (
- auditDescriptorKeyPattern.test(key) && isSensitiveAuditDescriptorValue(child)
- ));
- }
- function isSensitiveAuditDescriptorValue(value) {
- if (Array.isArray(value)) return value.some(isSensitiveAuditDescriptorValue);
- if (value && typeof value === 'object') return Object.values(value).some(isSensitiveAuditDescriptorValue);
- return auditDescriptorValuePattern.test(String(value ?? ''));
- }
- function addAuditFilter(where, params, column, value) {
- if (value === undefined) return;
- if (value === null) {
- where.push(`${column} IS NULL`);
- return;
- }
- where.push(`${column} = ?`);
- params.push(value);
- }
- function addAuditDateFilter(where, params, column, operator, value) {
- if (value === undefined || value === null || value === '') return;
- where.push(`${column} ${operator} ?`);
- params.push(value);
- }
- function normalizeQueueId(value) {
- return String(value || '').trim().toUpperCase();
- }
- function extractQueueIdFromText(value) {
- return String(value || '').match(/\bqueued as\s+([A-Z0-9]{5,})\b/i)?.[1]?.toUpperCase() || '';
- }
- function normalizeDeliveryAttempt(attempt, queueId) {
- return {
- at: attempt?.at || now(),
- source: attempt?.source || 'postfix',
- queueId,
- recipient: String(attempt?.recipient || '').toLowerCase(),
- relay: String(attempt?.relay || ''),
- dsn: String(attempt?.dsn || ''),
- status: String(attempt?.status || 'unknown').toLowerCase(),
- response: String(attempt?.response || ''),
- raw: String(attempt?.raw || '')
- };
- }
- function deliveryAttemptKey(attempt) {
- return attempt.raw || [
- attempt.queueId,
- attempt.recipient,
- attempt.status,
- attempt.dsn,
- attempt.response
- ].join('|');
- }
- function deliveryStatusForEvent(recipients, attempts, currentStatus) {
- const byRecipient = new Map();
- for (const attempt of attempts) {
- if (attempt.recipient) byRecipient.set(String(attempt.recipient).toLowerCase(), attempt.status);
- }
- const normalizedRecipients = recipients.map((recipient) => String(recipient || '').toLowerCase()).filter(Boolean);
- const statuses = normalizedRecipients.map((recipient) => byRecipient.get(recipient)).filter(Boolean);
- if (normalizedRecipients.length && statuses.length === normalizedRecipients.length && statuses.every((status) => status === 'sent')) {
- return 'sent';
- }
- if (statuses.includes('deferred')) return 'deferred';
- if (statuses.includes('bounced')) return 'bounced';
- return currentStatus || attempts.at(-1)?.status || 'queued';
- }
- function deliveryAttemptDetail(attempt) {
- const parts = [
- attempt.status,
- attempt.recipient ? `to ${attempt.recipient}` : '',
- attempt.relay ? `via ${attempt.relay}` : '',
- attempt.dsn ? `dsn=${attempt.dsn}` : ''
- ].filter(Boolean);
- return `${parts.join(' ')}${attempt.response ? `; ${attempt.response}` : ''}`;
- }
- function isDeliveryFailureStatus(status) {
- return ['deferred', 'bounced', 'failed'].includes(String(status || '').toLowerCase());
- }
- 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);
- }
|