db.js 60 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730
  1. import { mkdirSync } from 'node:fs';
  2. import crypto from 'node:crypto';
  3. import path from 'node:path';
  4. import { DatabaseSync } from 'node:sqlite';
  5. import { dkimPublicFromPrivateKey } from './dkim.js';
  6. let db;
  7. let secretKey = '';
  8. export const USER_STATUSES = new Set(['pending_email', 'pending_review', 'active', 'disabled']);
  9. const auditSecretKeyPattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
  10. const auditDescriptorKeyPattern = /^(field|name|path|key|header)$/i;
  11. const auditDescriptorValuePattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
  12. const auditDescriptorWrapperKeyPattern = /^(change|context|descriptor|meta)$/i;
  13. const auditValueLikeKeyPattern = /^(value|from|to|old|new|old_?value|new_?value|before|after)$/i;
  14. const maxAccountTokenTtlMinutes = 7 * 24 * 60;
  15. export function initDatabase(dataDir, secret = '') {
  16. secretKey = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
  17. mkdirSync(dataDir, { recursive: true });
  18. db = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'));
  19. db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
  20. migrateLegacySmtpTable();
  21. db.exec(`
  22. CREATE TABLE IF NOT EXISTS users (
  23. id INTEGER PRIMARY KEY AUTOINCREMENT,
  24. username TEXT NOT NULL UNIQUE,
  25. email TEXT NOT NULL UNIQUE,
  26. password_hash TEXT NOT NULL,
  27. role TEXT NOT NULL DEFAULT 'user',
  28. status TEXT NOT NULL DEFAULT 'active',
  29. created_at TEXT NOT NULL,
  30. updated_at TEXT NOT NULL
  31. );
  32. CREATE TABLE IF NOT EXISTS domains (
  33. id INTEGER PRIMARY KEY AUTOINCREMENT,
  34. user_id INTEGER,
  35. dns_credential_id INTEGER,
  36. domain TEXT NOT NULL UNIQUE,
  37. selector TEXT NOT NULL,
  38. verification_token TEXT NOT NULL,
  39. dkim_public TEXT NOT NULL,
  40. dkim_private TEXT NOT NULL,
  41. sender_host TEXT NOT NULL,
  42. sending_ip TEXT NOT NULL,
  43. spf_extra TEXT NOT NULL DEFAULT '',
  44. dmarc_policy TEXT NOT NULL DEFAULT 'none',
  45. dmarc_rua TEXT NOT NULL DEFAULT '',
  46. status_json TEXT NOT NULL DEFAULT '{}',
  47. created_at TEXT NOT NULL,
  48. updated_at TEXT NOT NULL
  49. );
  50. CREATE TABLE IF NOT EXISTS send_events (
  51. id INTEGER PRIMARY KEY AUTOINCREMENT,
  52. user_id INTEGER,
  53. domain_id INTEGER,
  54. sender TEXT NOT NULL,
  55. recipients TEXT NOT NULL,
  56. subject TEXT NOT NULL,
  57. status TEXT NOT NULL,
  58. detail TEXT NOT NULL DEFAULT '',
  59. queue_id TEXT NOT NULL DEFAULT '',
  60. delivery_log_json TEXT NOT NULL DEFAULT '[]',
  61. delivery_attempts_json TEXT NOT NULL DEFAULT '[]',
  62. delivered_at TEXT,
  63. created_at TEXT NOT NULL,
  64. FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE SET NULL
  65. );
  66. CREATE TABLE IF NOT EXISTS audit_logs (
  67. id INTEGER PRIMARY KEY AUTOINCREMENT,
  68. actor_user_id INTEGER,
  69. action TEXT NOT NULL,
  70. target_type TEXT NOT NULL,
  71. target_id TEXT NOT NULL DEFAULT '',
  72. target_user_id INTEGER,
  73. summary_json TEXT NOT NULL DEFAULT '{}',
  74. created_at TEXT NOT NULL
  75. );
  76. CREATE TABLE IF NOT EXISTS smtp_credentials (
  77. id INTEGER PRIMARY KEY AUTOINCREMENT,
  78. user_id INTEGER NOT NULL UNIQUE,
  79. username TEXT NOT NULL UNIQUE,
  80. password_hash TEXT NOT NULL,
  81. password_secret TEXT NOT NULL DEFAULT '',
  82. created_at TEXT NOT NULL,
  83. updated_at TEXT NOT NULL,
  84. FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
  85. );
  86. CREATE TABLE IF NOT EXISTS api_tokens (
  87. id INTEGER PRIMARY KEY AUTOINCREMENT,
  88. user_id INTEGER NOT NULL,
  89. name TEXT NOT NULL,
  90. token_hash TEXT NOT NULL UNIQUE,
  91. token_prefix TEXT NOT NULL,
  92. last_used_at TEXT,
  93. created_at TEXT NOT NULL,
  94. FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
  95. );
  96. CREATE TABLE IF NOT EXISTS account_tokens (
  97. id INTEGER PRIMARY KEY AUTOINCREMENT,
  98. user_id INTEGER NOT NULL,
  99. purpose TEXT NOT NULL,
  100. token_hash TEXT NOT NULL UNIQUE,
  101. expires_at TEXT NOT NULL,
  102. used_at TEXT,
  103. created_at TEXT NOT NULL,
  104. FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
  105. );
  106. CREATE TABLE IF NOT EXISTS dns_credentials (
  107. id INTEGER PRIMARY KEY AUTOINCREMENT,
  108. user_id INTEGER NOT NULL,
  109. name TEXT NOT NULL,
  110. provider TEXT NOT NULL,
  111. zone_name TEXT NOT NULL DEFAULT '',
  112. default_ttl INTEGER NOT NULL DEFAULT 600,
  113. credentials_secret TEXT NOT NULL,
  114. created_at TEXT NOT NULL,
  115. updated_at TEXT NOT NULL,
  116. FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
  117. );
  118. CREATE TABLE IF NOT EXISTS app_settings (
  119. key TEXT PRIMARY KEY,
  120. value TEXT NOT NULL,
  121. updated_at TEXT NOT NULL
  122. );
  123. CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON api_tokens(user_id);
  124. CREATE INDEX IF NOT EXISTS idx_account_tokens_user_purpose ON account_tokens(user_id, purpose);
  125. CREATE INDEX IF NOT EXISTS idx_account_tokens_expires_at ON account_tokens(expires_at);
  126. CREATE INDEX IF NOT EXISTS idx_dns_credentials_user_id ON dns_credentials(user_id);
  127. CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
  128. CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_user_id ON audit_logs(actor_user_id);
  129. CREATE INDEX IF NOT EXISTS idx_audit_logs_target_user_id ON audit_logs(target_user_id);
  130. CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
  131. `);
  132. ensureColumn('domains', 'user_id', 'INTEGER');
  133. ensureColumn('domains', 'dns_credential_id', 'INTEGER');
  134. ensureColumn('send_events', 'user_id', 'INTEGER');
  135. ensureColumn('send_events', 'queue_id', "TEXT NOT NULL DEFAULT ''");
  136. ensureColumn('send_events', 'delivery_log_json', "TEXT NOT NULL DEFAULT '[]'");
  137. ensureColumn('send_events', 'delivery_attempts_json', "TEXT NOT NULL DEFAULT '[]'");
  138. ensureColumn('send_events', 'delivered_at', 'TEXT');
  139. ensureColumn('smtp_credentials', 'password_secret', "TEXT NOT NULL DEFAULT ''");
  140. db.exec(`
  141. CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
  142. CREATE INDEX IF NOT EXISTS idx_events_user_id ON send_events(user_id);
  143. CREATE INDEX IF NOT EXISTS idx_events_queue_id ON send_events(queue_id);
  144. `);
  145. normalizeSendEventQueueIds();
  146. normalizeDkimPublicKeys();
  147. return db;
  148. }
  149. export function seedAdminUser({ username, password, email }) {
  150. const normalizedUsername = normalizeUsername(username || 'admin');
  151. const normalizedEmail = normalizeEmail(email || `${normalizedUsername}@mailhub.local`);
  152. const existing = getUserByLogin(normalizedUsername) || getUserByLogin(normalizedEmail);
  153. if (existing) {
  154. requireDb()
  155. .prepare('UPDATE users SET role = ?, status = ?, updated_at = ? WHERE id = ?')
  156. .run('admin', 'active', now(), existing.id);
  157. return getUser(existing.id);
  158. }
  159. return createUser({
  160. username: normalizedUsername,
  161. email: normalizedEmail,
  162. password,
  163. role: 'admin',
  164. status: 'active'
  165. });
  166. }
  167. export function claimLegacyData(userId) {
  168. requireDb().prepare('UPDATE domains SET user_id = ? WHERE user_id IS NULL').run(userId);
  169. requireDb().prepare(`
  170. UPDATE send_events
  171. SET user_id = COALESCE((SELECT user_id FROM domains WHERE domains.id = send_events.domain_id), ?)
  172. WHERE user_id IS NULL
  173. `).run(userId);
  174. if (!tableExists('smtp_credentials_legacy') || getSmtpCredential(userId)) return;
  175. const legacy = requireDb().prepare('SELECT * FROM smtp_credentials_legacy WHERE id = 1').get();
  176. if (!legacy?.username || !legacy?.password_hash) return;
  177. const createdAt = now();
  178. requireDb()
  179. .prepare(`
  180. INSERT INTO smtp_credentials (user_id, username, password_hash, password_secret, created_at, updated_at)
  181. VALUES (?, ?, ?, ?, ?, ?)
  182. `)
  183. .run(userId, legacy.username, legacy.password_hash, legacy.password_secret || '', createdAt, createdAt);
  184. }
  185. export function seedSmtpCredential(userId, username, password) {
  186. if (!userId || !username || !password || getSmtpCredential(userId)) return null;
  187. return saveSmtpCredential(userId, { username, password });
  188. }
  189. export function createUser({ username, email, password, role = 'user', status = 'active' }) {
  190. const cleanUsername = normalizeUsername(username);
  191. const cleanEmail = normalizeEmail(email);
  192. const cleanStatus = normalizeUserStatus(status);
  193. if (!cleanUsername) throw new Error('用户名格式不正确。');
  194. if (!cleanEmail) throw new Error('邮箱格式不正确。');
  195. if (String(password || '').length < 8) throw new Error('密码至少需要 8 位。');
  196. const createdAt = now();
  197. const result = requireDb()
  198. .prepare(`
  199. INSERT INTO users (username, email, password_hash, role, status, created_at, updated_at)
  200. VALUES (?, ?, ?, ?, ?, ?, ?)
  201. `)
  202. .run(cleanUsername, cleanEmail, hashPassword(password), role === 'admin' ? 'admin' : 'user', cleanStatus, createdAt, createdAt);
  203. return getUser(result.lastInsertRowid);
  204. }
  205. export function createUserWithAccountToken(userInput, tokenPurpose, { ttlMinutes } = {}) {
  206. const database = requireDb();
  207. database.exec('BEGIN');
  208. try {
  209. const user = createUser(userInput);
  210. const accountToken = createAccountToken(user.id, tokenPurpose, { ttlMinutes });
  211. database.exec('COMMIT');
  212. return { user, accountToken };
  213. } catch (error) {
  214. database.exec('ROLLBACK');
  215. throw error;
  216. }
  217. }
  218. export function authenticateUser(login, password) {
  219. const user = verifyUserCredentials(login, password);
  220. return user?.status === 'active' ? user : null;
  221. }
  222. export function verifyUserCredentials(login, password) {
  223. const user = getUserByLogin(login, { includeHash: true });
  224. if (!user || !verifyPassword(password, user.passwordHash)) return null;
  225. return publicUser(user);
  226. }
  227. export function listUsers() {
  228. return requireDb()
  229. .prepare('SELECT * FROM users ORDER BY created_at DESC')
  230. .all()
  231. .map(publicUser);
  232. }
  233. export function listUsersWithResourceCounts() {
  234. return requireDb()
  235. .prepare(`
  236. SELECT
  237. users.*,
  238. (SELECT COUNT(*) FROM domains WHERE domains.user_id = users.id) AS domains_count,
  239. (SELECT COUNT(*) FROM dns_credentials WHERE dns_credentials.user_id = users.id) AS dns_credentials_count,
  240. (SELECT COUNT(*) FROM api_tokens WHERE api_tokens.user_id = users.id) AS api_tokens_count,
  241. (SELECT COUNT(*) FROM send_events WHERE send_events.user_id = users.id) AS send_events_count,
  242. (SELECT COUNT(*) FROM smtp_credentials WHERE smtp_credentials.user_id = users.id) AS smtp_credentials_count
  243. FROM users
  244. ORDER BY users.created_at DESC
  245. `)
  246. .all()
  247. .map((row) => ({
  248. ...publicUser(row),
  249. resourceCounts: {
  250. domains: Number(row.domains_count || 0),
  251. dnsCredentials: Number(row.dns_credentials_count || 0),
  252. apiTokens: Number(row.api_tokens_count || 0),
  253. sendEvents: Number(row.send_events_count || 0),
  254. smtpCredential: Number(row.smtp_credentials_count || 0) > 0 ? 1 : 0
  255. }
  256. }));
  257. }
  258. export function getAdminResourceInventory() {
  259. const users = listUsersWithResourceCounts();
  260. const domains = requireDb()
  261. .prepare('SELECT * FROM domains ORDER BY user_id, created_at DESC')
  262. .all()
  263. .map(publicDomainRow);
  264. const dnsCredentials = requireDb()
  265. .prepare('SELECT * FROM dns_credentials ORDER BY user_id, created_at DESC')
  266. .all()
  267. .map(publicDnsCredential);
  268. const smtpCredentials = requireDb()
  269. .prepare('SELECT * FROM smtp_credentials ORDER BY user_id')
  270. .all()
  271. .map(publicSmtpCredential);
  272. const apiTokens = requireDb()
  273. .prepare('SELECT * FROM api_tokens ORDER BY user_id, created_at DESC')
  274. .all()
  275. .map(publicApiToken);
  276. const sendEventCounts = new Map(
  277. requireDb()
  278. .prepare('SELECT user_id, COUNT(*) AS count FROM send_events GROUP BY user_id')
  279. .all()
  280. .map((row) => [row.user_id, Number(row.count || 0)])
  281. );
  282. const dnsCredentialById = new Map(dnsCredentials.map((credential) => [credential.id, credential]));
  283. return {
  284. users: users.map((user) => ({
  285. user,
  286. domains: domains.filter((domain) => domain.userId === user.id),
  287. dnsCredentials: dnsCredentials.filter((credential) => credential.userId === user.id),
  288. smtpCredential: smtpCredentials.find((credential) => credential.userId === user.id) || null,
  289. apiTokens: apiTokens.filter((token) => token.userId === user.id),
  290. sendEventCount: sendEventCounts.get(user.id) || 0
  291. })),
  292. warnings: domains.flatMap((domain) => {
  293. if (!domain.dnsCredentialId) return [];
  294. const credential = dnsCredentialById.get(domain.dnsCredentialId);
  295. if (!credential || credential.userId === domain.userId) return [];
  296. return [{
  297. type: 'domain_dns_credential_owner_mismatch',
  298. domainId: domain.id,
  299. domain: domain.domain,
  300. domainUserId: domain.userId,
  301. dnsCredentialId: credential.id,
  302. dnsCredentialUserId: credential.userId
  303. }];
  304. })
  305. };
  306. }
  307. export function transferDomain({ actorUserId, domainId, targetUserId, dnsCredentialMode = 'domain_only' }) {
  308. return withTransaction(() => {
  309. const target = requireTransferTargetUser(targetUserId);
  310. const domain = requireDomainRow(domainId);
  311. const mode = normalizeDnsCredentialTransferMode(dnsCredentialMode);
  312. const nextDnsCredentialId = mode === 'clear_dns_credential' ? null : domain.dns_credential_id;
  313. requireDb()
  314. .prepare('UPDATE domains SET user_id = ?, dns_credential_id = ?, updated_at = ? WHERE id = ?')
  315. .run(target.id, nextDnsCredentialId, now(), domain.id);
  316. if (mode === 'with_dns_credential' && domain.dns_credential_id) {
  317. const credential = requireDnsCredentialRow(domain.dns_credential_id);
  318. if (credential.user_id !== domain.user_id) throw new Error('DNS 凭据归属不一致。');
  319. requireDb()
  320. .prepare('UPDATE dns_credentials SET user_id = ?, updated_at = ? WHERE id = ?')
  321. .run(target.id, now(), domain.dns_credential_id);
  322. }
  323. const updated = getDomain(domain.id);
  324. logAudit({
  325. actorUserId,
  326. action: 'admin.transfer_domain',
  327. targetType: 'domain',
  328. targetId: String(domain.id),
  329. targetUserId: target.id,
  330. summary: {
  331. domain: domain.domain,
  332. fromUserId: domain.user_id,
  333. toUserId: target.id,
  334. dnsCredentialMode: mode,
  335. dnsCredentialId: domain.dns_credential_id || null
  336. }
  337. });
  338. return updated;
  339. });
  340. }
  341. export function transferDnsCredential({ actorUserId, credentialId, targetUserId }) {
  342. return withTransaction(() => {
  343. const target = requireTransferTargetUser(targetUserId);
  344. const credential = requireDnsCredentialRow(credentialId);
  345. requireDb()
  346. .prepare('UPDATE dns_credentials SET user_id = ?, updated_at = ? WHERE id = ?')
  347. .run(target.id, now(), credential.id);
  348. const updated = publicDnsCredential(requireDnsCredentialRow(credential.id));
  349. logAudit({
  350. actorUserId,
  351. action: 'admin.transfer_dns_credential',
  352. targetType: 'dns_credential',
  353. targetId: String(credential.id),
  354. targetUserId: target.id,
  355. summary: {
  356. name: credential.name,
  357. provider: credential.provider,
  358. fromUserId: credential.user_id,
  359. toUserId: target.id
  360. }
  361. });
  362. return updated;
  363. });
  364. }
  365. export function transferApiTokens({ actorUserId, tokenIds, targetUserId }) {
  366. return withTransaction(() => {
  367. const target = requireTransferTargetUser(targetUserId);
  368. const ids = uniquePositiveIds(tokenIds);
  369. if (!ids.length) throw new Error('API Token 不存在。');
  370. const placeholders = ids.map(() => '?').join(', ');
  371. const tokens = requireDb()
  372. .prepare(`SELECT * FROM api_tokens WHERE id IN (${placeholders})`)
  373. .all(...ids);
  374. if (tokens.length !== ids.length) throw new Error('API Token 不存在。');
  375. requireDb()
  376. .prepare(`UPDATE api_tokens SET user_id = ? WHERE id IN (${placeholders})`)
  377. .run(target.id, ...ids);
  378. const updated = requireDb()
  379. .prepare(`SELECT * FROM api_tokens WHERE id IN (${placeholders}) ORDER BY created_at DESC`)
  380. .all(...ids)
  381. .map(publicApiToken);
  382. logAudit({
  383. actorUserId,
  384. action: 'admin.transfer_api_tokens',
  385. targetType: 'api_token',
  386. targetId: ids.join(','),
  387. targetUserId: target.id,
  388. summary: {
  389. tokenIds: ids,
  390. count: ids.length,
  391. fromUserIds: [...new Set(tokens.map((token) => token.user_id))],
  392. toUserId: target.id
  393. }
  394. });
  395. return updated;
  396. });
  397. }
  398. export function previewUserMerge({ sourceUserId, targetUserId }) {
  399. const { source, target } = requireMergeUsers(sourceUserId, targetUserId);
  400. const sourceSmtp = getSmtpCredential(source.id);
  401. const targetSmtp = getSmtpCredential(target.id);
  402. const counts = {
  403. domains: countRows('domains', source.id),
  404. dnsCredentials: countRows('dns_credentials', source.id),
  405. apiTokens: countRows('api_tokens', source.id),
  406. sendEvents: countRows('send_events', source.id),
  407. smtpCredential: sourceSmtp ? 1 : 0
  408. };
  409. const smtpConflict = Boolean(sourceSmtp && targetSmtp);
  410. const defaultOptions = {
  411. transferDomains: true,
  412. transferDnsCredentials: true,
  413. transferApiTokens: true,
  414. transferSendEvents: true,
  415. transferSmtpCredential: Boolean(sourceSmtp && !targetSmtp),
  416. disableSource: true
  417. };
  418. const selectedCounts = {
  419. domains: counts.domains,
  420. dnsCredentials: counts.dnsCredentials,
  421. apiTokens: counts.apiTokens,
  422. sendEvents: counts.sendEvents,
  423. smtpCredential: defaultOptions.transferSmtpCredential ? counts.smtpCredential : 0
  424. };
  425. return {
  426. sourceUser: source,
  427. targetUser: target,
  428. confirmationText: `MERGE ${source.username} INTO ${target.username}`,
  429. counts,
  430. selectedCounts,
  431. defaultOptions,
  432. resources: {
  433. source: mergeResourcesForUser(source.id),
  434. target: mergeResourcesForUser(target.id)
  435. },
  436. smtp: {
  437. sourceCredential: sourceSmtp,
  438. targetCredential: targetSmtp,
  439. conflict: smtpConflict
  440. },
  441. warnings: smtpConflict ? [{
  442. type: 'smtp_credential_conflict',
  443. message: '目标用户已有 SMTP 凭据,源用户 SMTP 凭据需要手动处理。'
  444. }] : []
  445. };
  446. }
  447. export function executeUserMerge({ actorUserId, sourceUserId, targetUserId, options = {}, confirmation }) {
  448. return withTransaction(() => {
  449. const preview = previewUserMerge({ sourceUserId, targetUserId });
  450. if (confirmation !== preview.confirmationText) throw new Error('确认文本不匹配。');
  451. const sourceId = preview.sourceUser.id;
  452. const targetId = preview.targetUser.id;
  453. const counts = {
  454. domains: options.transferDomains === false ? 0 : moveRows('domains', sourceId, targetId),
  455. dnsCredentials: options.transferDnsCredentials === false ? 0 : moveRows('dns_credentials', sourceId, targetId),
  456. apiTokens: options.transferApiTokens === false ? 0 : moveRows('api_tokens', sourceId, targetId),
  457. sendEvents: options.transferSendEvents === false ? 0 : moveRows('send_events', sourceId, targetId),
  458. smtpCredential: 0
  459. };
  460. if (options.transferSmtpCredential !== false && preview.smtp.sourceCredential && !preview.smtp.targetCredential) {
  461. counts.smtpCredential = moveRows('smtp_credentials', sourceId, targetId);
  462. }
  463. if (options.disableSource !== false) {
  464. requireDb()
  465. .prepare("UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?")
  466. .run(now(), sourceId);
  467. }
  468. logAudit({
  469. actorUserId,
  470. action: 'admin.user_merge',
  471. targetType: 'user',
  472. targetId: String(targetId),
  473. targetUserId: targetId,
  474. summary: {
  475. sourceUserId: sourceId,
  476. sourceUsername: preview.sourceUser.username,
  477. targetUserId: targetId,
  478. targetUsername: preview.targetUser.username,
  479. counts,
  480. warnings: preview.warnings
  481. }
  482. });
  483. return {
  484. sourceUser: getUser(sourceId),
  485. targetUser: getUser(targetId),
  486. counts,
  487. warnings: preview.warnings
  488. };
  489. });
  490. }
  491. export function getUser(id, { includeHash = false } = {}) {
  492. const row = requireDb().prepare('SELECT * FROM users WHERE id = ?').get(id);
  493. return includeHash ? privateUser(row) : publicUser(row);
  494. }
  495. export function getUserByLogin(login, { includeHash = false } = {}) {
  496. const value = String(login || '').trim().toLowerCase();
  497. if (!value) return null;
  498. const row = requireDb()
  499. .prepare('SELECT * FROM users WHERE lower(username) = ? OR lower(email) = ?')
  500. .get(value, value);
  501. return includeHash ? privateUser(row) : publicUser(row);
  502. }
  503. export function updateUser(id, patch) {
  504. const current = getUser(id, { includeHash: true });
  505. if (!current) return null;
  506. const passwordChanged = String(patch.password || '').length > 0;
  507. if (passwordChanged && String(patch.password).length < 8) throw new Error('密码至少需要 8 位。');
  508. const next = {
  509. role: patch.role === 'admin' ? 'admin' : current.role,
  510. status: patch.status === undefined ? current.status : normalizeUserStatus(patch.status),
  511. passwordHash: passwordChanged ? hashPassword(patch.password) : current.passwordHash,
  512. updatedAt: now()
  513. };
  514. requireDb()
  515. .prepare('UPDATE users SET role = ?, status = ?, password_hash = ?, updated_at = ? WHERE id = ?')
  516. .run(next.role, next.status, next.passwordHash, next.updatedAt, id);
  517. if (passwordChanged) invalidateAccountTokens(id, 'password_reset');
  518. return getUser(id);
  519. }
  520. export function updateUserStatus(id, status) {
  521. const nextStatus = normalizeUserStatus(status);
  522. if (!getUser(id)) return null;
  523. requireDb()
  524. .prepare('UPDATE users SET status = ?, updated_at = ? WHERE id = ?')
  525. .run(nextStatus, now(), id);
  526. return getUser(id);
  527. }
  528. export function approveUser(id) {
  529. return updateUserStatus(id, 'active');
  530. }
  531. export function markUserEmailVerified(id) {
  532. const user = getUser(id);
  533. if (!user) return null;
  534. if (user.status !== 'pending_email') return user;
  535. return updateUserStatus(id, 'pending_review');
  536. }
  537. export function getAdminUser() {
  538. const row = requireDb()
  539. .prepare("SELECT * FROM users WHERE role = 'admin' AND status = 'active' ORDER BY id LIMIT 1")
  540. .get();
  541. return publicUser(row);
  542. }
  543. export function listDomains(userId) {
  544. return requireDb()
  545. .prepare('SELECT * FROM domains WHERE user_id = ? ORDER BY created_at DESC')
  546. .all(userId)
  547. .map(publicDomainRow);
  548. }
  549. export function getDomain(id, { userId, includePrivate = false } = {}) {
  550. const row = requireDb()
  551. .prepare('SELECT * FROM domains WHERE id = ? AND (? IS NULL OR user_id = ?)')
  552. .get(id, userId ?? null, userId ?? null);
  553. return includePrivate ? privateDomainRow(row) : publicDomainRow(row);
  554. }
  555. export function getDomainByName(domain, { userId, includePrivate = false } = {}) {
  556. const row = requireDb()
  557. .prepare('SELECT * FROM domains WHERE domain = ? AND (? IS NULL OR user_id = ?)')
  558. .get(String(domain || '').toLowerCase(), userId ?? null, userId ?? null);
  559. return includePrivate ? privateDomainRow(row) : publicDomainRow(row);
  560. }
  561. export function createDomain(userId, domain) {
  562. const createdAt = now();
  563. const result = requireDb()
  564. .prepare(`
  565. INSERT INTO domains (
  566. user_id, dns_credential_id, domain, selector, verification_token,
  567. dkim_public, dkim_private, sender_host, sending_ip, spf_extra,
  568. dmarc_policy, dmarc_rua, status_json, created_at, updated_at
  569. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)
  570. `)
  571. .run(
  572. userId,
  573. domain.dnsCredentialId || null,
  574. domain.domain,
  575. domain.selector,
  576. domain.verificationToken,
  577. domain.dkimPublic,
  578. domain.dkimPrivate,
  579. domain.senderHost,
  580. domain.sendingIp,
  581. domain.spfExtra,
  582. domain.dmarcPolicy,
  583. domain.dmarcRua,
  584. createdAt,
  585. createdAt
  586. );
  587. return getDomain(result.lastInsertRowid, { userId });
  588. }
  589. export function updateDomain(id, userId, patch) {
  590. const current = getDomain(id, { userId, includePrivate: true });
  591. if (!current) return null;
  592. const next = {
  593. selector: patch.selector ?? current.selector,
  594. dnsCredentialId: patch.dnsCredentialId === undefined ? current.dnsCredentialId : (patch.dnsCredentialId || null),
  595. senderHost: patch.senderHost ?? current.senderHost,
  596. sendingIp: patch.sendingIp ?? current.sendingIp,
  597. spfExtra: patch.spfExtra ?? current.spfExtra,
  598. dmarcPolicy: patch.dmarcPolicy ?? current.dmarcPolicy,
  599. dmarcRua: patch.dmarcRua ?? current.dmarcRua,
  600. updatedAt: now()
  601. };
  602. requireDb()
  603. .prepare(`
  604. UPDATE domains
  605. SET selector = ?, dns_credential_id = ?, sender_host = ?, sending_ip = ?, spf_extra = ?,
  606. dmarc_policy = ?, dmarc_rua = ?, updated_at = ?
  607. WHERE id = ? AND user_id = ?
  608. `)
  609. .run(
  610. next.selector,
  611. next.dnsCredentialId,
  612. next.senderHost,
  613. next.sendingIp,
  614. next.spfExtra,
  615. next.dmarcPolicy,
  616. next.dmarcRua,
  617. next.updatedAt,
  618. id,
  619. userId
  620. );
  621. return getDomain(id, { userId });
  622. }
  623. export function updateDkim(id, userId, keys, selector) {
  624. requireDb()
  625. .prepare('UPDATE domains SET selector = ?, dkim_public = ?, dkim_private = ?, updated_at = ? WHERE id = ? AND user_id = ?')
  626. .run(selector, keys.publicKey, keys.privateKey, now(), id, userId);
  627. return getDomain(id, { userId });
  628. }
  629. export function saveDomainStatus(id, userId, status) {
  630. requireDb()
  631. .prepare('UPDATE domains SET status_json = ?, updated_at = ? WHERE id = ? AND user_id = ?')
  632. .run(JSON.stringify(status), now(), id, userId);
  633. }
  634. export function deleteDomain(id, userId) {
  635. const result = requireDb().prepare('DELETE FROM domains WHERE id = ? AND user_id = ?').run(id, userId);
  636. return result.changes > 0;
  637. }
  638. export function logAudit({ actorUserId, action, targetType, targetId = '', targetUserId = null, summary = {} }) {
  639. const result = requireDb()
  640. .prepare(`
  641. INSERT INTO audit_logs (
  642. actor_user_id, action, target_type, target_id, target_user_id, summary_json, created_at
  643. )
  644. VALUES (?, ?, ?, ?, ?, ?, ?)
  645. `)
  646. .run(
  647. actorUserId ?? null,
  648. String(action || ''),
  649. String(targetType || ''),
  650. String(targetId ?? ''),
  651. targetUserId ?? null,
  652. JSON.stringify(sanitizeAuditSummary(summary)),
  653. now()
  654. );
  655. return result.lastInsertRowid;
  656. }
  657. export function listAuditLogs(filters = {}) {
  658. const where = [];
  659. const params = [];
  660. addAuditFilter(where, params, 'actor_user_id', filters.actorUserId);
  661. addAuditFilter(where, params, 'target_user_id', filters.targetUserId);
  662. addAuditFilter(where, params, 'action', filters.action);
  663. addAuditDateFilter(where, params, 'created_at', '>=', filters.from);
  664. addAuditDateFilter(where, params, 'created_at', '<=', filters.to);
  665. const query = `
  666. SELECT *
  667. FROM audit_logs
  668. ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
  669. ORDER BY created_at DESC, id DESC
  670. `;
  671. return requireDb().prepare(query).all(...params).map(publicAuditLog);
  672. }
  673. export function logSendEvent(event) {
  674. const queueId = normalizeQueueId(event.queueId || extractQueueIdFromText(event.detail));
  675. const result = requireDb()
  676. .prepare(`
  677. INSERT INTO send_events (
  678. user_id, domain_id, sender, recipients, subject, status, detail, queue_id,
  679. delivery_log_json, delivery_attempts_json, delivered_at, created_at
  680. )
  681. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  682. `)
  683. .run(
  684. event.userId ?? null,
  685. event.domainId ?? null,
  686. event.sender,
  687. JSON.stringify(event.recipients),
  688. event.subject,
  689. event.status,
  690. event.detail ?? '',
  691. queueId,
  692. JSON.stringify(Array.isArray(event.deliveryLog) ? event.deliveryLog : []),
  693. JSON.stringify(Array.isArray(event.deliveryAttempts) ? event.deliveryAttempts : []),
  694. event.deliveredAt ?? null,
  695. now()
  696. );
  697. return result.lastInsertRowid;
  698. }
  699. export function updateSendEventDelivery(queueId, attempt) {
  700. const cleanQueueId = normalizeQueueId(queueId || attempt?.queueId);
  701. if (!cleanQueueId) return false;
  702. const row = requireDb().prepare('SELECT * FROM send_events WHERE queue_id = ? ORDER BY id DESC LIMIT 1').get(cleanQueueId);
  703. if (!row) return false;
  704. const normalizedAttempt = normalizeDeliveryAttempt(attempt, cleanQueueId);
  705. const attempts = safeJson(row.delivery_attempts_json, []);
  706. if (attempts.some((item) => deliveryAttemptKey(item) === deliveryAttemptKey(normalizedAttempt))) return true;
  707. const nextAttempts = [...attempts, normalizedAttempt];
  708. const recipients = safeJson(row.recipients, []);
  709. const nextStatus = deliveryStatusForEvent(recipients, nextAttempts, row.status);
  710. const deliveredAt = nextStatus === 'sent' ? normalizedAttempt.at : row.delivered_at;
  711. requireDb()
  712. .prepare(`
  713. UPDATE send_events
  714. SET status = ?, detail = ?, delivery_attempts_json = ?, delivered_at = ?
  715. WHERE id = ?
  716. `)
  717. .run(nextStatus, deliveryAttemptDetail(normalizedAttempt), JSON.stringify(nextAttempts), deliveredAt, row.id);
  718. return true;
  719. }
  720. export function listSendEvents(userId, limit = 30) {
  721. return requireDb()
  722. .prepare(`
  723. SELECT e.*, d.domain
  724. FROM send_events e
  725. LEFT JOIN domains d ON d.id = e.domain_id
  726. WHERE e.user_id = ?
  727. ORDER BY e.created_at DESC
  728. LIMIT ?
  729. `)
  730. .all(userId, limit)
  731. .map((row) => ({
  732. id: row.id,
  733. userId: row.user_id,
  734. domainId: row.domain_id,
  735. domain: row.domain,
  736. sender: row.sender,
  737. recipients: safeJson(row.recipients, []),
  738. subject: row.subject,
  739. status: row.status,
  740. detail: row.detail,
  741. queueId: row.queue_id,
  742. deliveryLog: safeJson(row.delivery_log_json, []),
  743. deliveryAttempts: safeJson(row.delivery_attempts_json, []),
  744. deliveredAt: row.delivered_at,
  745. createdAt: row.created_at
  746. }));
  747. }
  748. export function getSendAnalytics(userId, { days = 30 } = {}) {
  749. const windowDays = clampAnalyticsDays(days);
  750. const since = new Date();
  751. since.setUTCHours(0, 0, 0, 0);
  752. since.setUTCDate(since.getUTCDate() - (windowDays - 1));
  753. const rows = requireDb()
  754. .prepare(`
  755. SELECT e.*, d.domain
  756. FROM send_events e
  757. LEFT JOIN domains d ON d.id = e.domain_id
  758. WHERE e.user_id = ? AND e.created_at >= ?
  759. ORDER BY e.created_at ASC
  760. LIMIT 5000
  761. `)
  762. .all(userId, since.toISOString());
  763. const domains = listDomains(userId);
  764. const dayBuckets = buildDayBuckets(windowDays);
  765. const byStatus = {};
  766. const byDomain = new Map();
  767. const hourly = Array.from({ length: 24 }, (_, hour) => ({
  768. hour,
  769. total: 0,
  770. queued: 0,
  771. failed: 0
  772. }));
  773. let recipients = 0;
  774. let queued = 0;
  775. let failed = 0;
  776. let today = 0;
  777. let last7Days = 0;
  778. const todayKey = new Date().toISOString().slice(0, 10);
  779. const weekStart = new Date();
  780. weekStart.setUTCHours(0, 0, 0, 0);
  781. weekStart.setUTCDate(weekStart.getUTCDate() - 6);
  782. for (const row of rows) {
  783. const status = row.status || 'unknown';
  784. const recipientList = safeJson(row.recipients, []);
  785. const recipientCount = Array.isArray(recipientList) ? recipientList.length : 0;
  786. const domainName = row.domain || (String(row.sender || '').split('@')[1] || 'unknown');
  787. const createdAt = new Date(row.created_at);
  788. const dayKey = row.created_at.slice(0, 10);
  789. const hour = Number.isInteger(createdAt.getUTCHours()) ? createdAt.getUTCHours() : 0;
  790. const isQueued = ['queued', 'sent'].includes(status);
  791. recipients += recipientCount;
  792. queued += isQueued ? 1 : 0;
  793. failed += isQueued ? 0 : 1;
  794. today += dayKey === todayKey ? 1 : 0;
  795. last7Days += createdAt >= weekStart ? 1 : 0;
  796. byStatus[status] = (byStatus[status] || 0) + 1;
  797. if (dayBuckets.has(dayKey)) {
  798. const bucket = dayBuckets.get(dayKey);
  799. bucket.total += 1;
  800. bucket.queued += isQueued ? 1 : 0;
  801. bucket.failed += isQueued ? 0 : 1;
  802. bucket.recipients += recipientCount;
  803. }
  804. const domainBucket = byDomain.get(domainName) || {
  805. domain: domainName,
  806. total: 0,
  807. queued: 0,
  808. failed: 0,
  809. recipients: 0
  810. };
  811. domainBucket.total += 1;
  812. domainBucket.queued += isQueued ? 1 : 0;
  813. domainBucket.failed += isQueued ? 0 : 1;
  814. domainBucket.recipients += recipientCount;
  815. byDomain.set(domainName, domainBucket);
  816. hourly[hour].total += 1;
  817. hourly[hour].queued += isQueued ? 1 : 0;
  818. hourly[hour].failed += isQueued ? 0 : 1;
  819. }
  820. const recentFailures = [...rows]
  821. .reverse()
  822. .filter((row) => isDeliveryFailureStatus(row.status))
  823. .slice(0, 8)
  824. .map((row) => ({
  825. id: row.id,
  826. domain: row.domain || '',
  827. sender: row.sender,
  828. subject: row.subject,
  829. detail: row.detail,
  830. createdAt: row.created_at
  831. }));
  832. return {
  833. windowDays,
  834. summary: {
  835. total: rows.length,
  836. queued,
  837. failed,
  838. recipients,
  839. today,
  840. last7Days,
  841. successRate: rows.length ? Math.round((queued / rows.length) * 1000) / 10 : 0,
  842. domains: domains.length,
  843. verifiedDomains: domains.filter((domain) => domain.status?.verified).length
  844. },
  845. byDay: [...dayBuckets.values()],
  846. byDomain: [...byDomain.values()].sort((a, b) => b.total - a.total).slice(0, 10),
  847. byStatus: Object.entries(byStatus).map(([status, total]) => ({ status, total })),
  848. hourly,
  849. recentFailures
  850. };
  851. }
  852. export function getSmtpCredential(userId, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
  853. const row = requireDb()
  854. .prepare('SELECT * FROM smtp_credentials WHERE user_id = ?')
  855. .get(userId);
  856. if (!row) return null;
  857. return publicSmtpCredential(row, { includeHash, includePassword, includeSecret });
  858. }
  859. export function saveSmtpCredential(userId, { username, password }) {
  860. const current = getSmtpCredential(userId, { includeHash: true, includeSecret: true });
  861. const nextUsername = String(username || current?.username || '').trim();
  862. if (!nextUsername) throw new Error('SMTP 用户名不能为空。');
  863. const nextHash = password ? hashPassword(password) : current?.passwordHash;
  864. if (!nextHash) throw new Error('SMTP 密码不能为空。');
  865. const nextSecret = password ? encryptSecret(password) : current?.passwordSecret || '';
  866. const updatedAt = now();
  867. if (current) {
  868. requireDb()
  869. .prepare('UPDATE smtp_credentials SET username = ?, password_hash = ?, password_secret = ?, updated_at = ? WHERE user_id = ?')
  870. .run(nextUsername, nextHash, nextSecret, updatedAt, userId);
  871. } else {
  872. requireDb()
  873. .prepare(`
  874. INSERT INTO smtp_credentials (user_id, username, password_hash, password_secret, created_at, updated_at)
  875. VALUES (?, ?, ?, ?, ?, ?)
  876. `)
  877. .run(userId, nextUsername, nextHash, nextSecret, updatedAt, updatedAt);
  878. }
  879. return getSmtpCredential(userId);
  880. }
  881. export function verifySmtpCredential(username, password) {
  882. const row = requireDb()
  883. .prepare(`
  884. SELECT c.*, u.id AS auth_user_id, u.username AS auth_username, u.email, u.role, u.status
  885. FROM smtp_credentials c
  886. JOIN users u ON u.id = c.user_id
  887. WHERE c.username = ?
  888. `)
  889. .get(String(username || '').trim());
  890. if (!row || row.status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
  891. return {
  892. user: {
  893. id: row.auth_user_id,
  894. username: row.auth_username,
  895. email: row.email,
  896. role: row.role,
  897. status: row.status
  898. },
  899. credential: publicSmtpCredential(row)
  900. };
  901. }
  902. export function createApiToken(userId, name) {
  903. const token = `mh_${crypto.randomBytes(32).toString('base64url')}`;
  904. const createdAt = now();
  905. const result = requireDb()
  906. .prepare(`
  907. INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, created_at)
  908. VALUES (?, ?, ?, ?, ?)
  909. `)
  910. .run(userId, String(name || 'API Token').trim() || 'API Token', tokenHash(token), token.slice(0, 12), createdAt);
  911. return {
  912. ...getApiToken(result.lastInsertRowid, userId),
  913. token
  914. };
  915. }
  916. export function listApiTokens(userId) {
  917. return requireDb()
  918. .prepare('SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC')
  919. .all(userId)
  920. .map(publicApiToken);
  921. }
  922. export function getApiToken(id, userId) {
  923. const row = requireDb()
  924. .prepare('SELECT * FROM api_tokens WHERE id = ? AND user_id = ?')
  925. .get(id, userId);
  926. return publicApiToken(row);
  927. }
  928. export function deleteApiToken(id, userId) {
  929. const result = requireDb().prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(id, userId);
  930. return result.changes > 0;
  931. }
  932. export function verifyApiToken(token) {
  933. const hash = tokenHash(token);
  934. const row = requireDb()
  935. .prepare(`
  936. SELECT t.*, u.id AS auth_user_id, u.username, u.email, u.role, u.status
  937. FROM api_tokens t
  938. JOIN users u ON u.id = t.user_id
  939. WHERE t.token_hash = ?
  940. `)
  941. .get(hash);
  942. if (!row || row.status !== 'active') return null;
  943. requireDb().prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id);
  944. return {
  945. id: row.auth_user_id,
  946. username: row.username,
  947. email: row.email,
  948. role: row.role,
  949. status: row.status
  950. };
  951. }
  952. export function createAccountToken(userId, purpose, { ttlMinutes } = {}) {
  953. const cleanPurpose = normalizeAccountTokenPurpose(purpose);
  954. const ttl = Number(ttlMinutes);
  955. if (!Number.isInteger(ttl) || ttl < 1 || ttl > maxAccountTokenTtlMinutes) throw new Error('令牌有效期不正确。');
  956. const token = crypto.randomBytes(32).toString('base64url');
  957. const createdAt = now();
  958. const expiresAt = new Date(Date.now() + ttl * 60 * 1000).toISOString();
  959. const result = requireDb()
  960. .prepare(`
  961. INSERT INTO account_tokens (user_id, purpose, token_hash, expires_at, created_at)
  962. VALUES (?, ?, ?, ?, ?)
  963. `)
  964. .run(userId, cleanPurpose, tokenHash(token), expiresAt, createdAt);
  965. return {
  966. ...publicAccountToken(getAccountTokenRow(result.lastInsertRowid)),
  967. token
  968. };
  969. }
  970. export function consumeAccountToken(token, purpose) {
  971. const rawToken = String(token || '');
  972. const cleanPurpose = String(purpose || '').trim();
  973. if (!rawToken || !cleanPurpose) return null;
  974. const tokenDigest = tokenHash(rawToken);
  975. const usedAt = now();
  976. const result = requireDb()
  977. .prepare(`
  978. UPDATE account_tokens
  979. SET used_at = ?
  980. WHERE token_hash = ? AND purpose = ? AND used_at IS NULL AND expires_at > ?
  981. `)
  982. .run(usedAt, tokenDigest, cleanPurpose, usedAt);
  983. if (result.changes === 0) return null;
  984. const row = requireDb()
  985. .prepare('SELECT * FROM account_tokens WHERE token_hash = ? AND purpose = ?')
  986. .get(tokenDigest, cleanPurpose);
  987. return publicAccountToken(row);
  988. }
  989. export function invalidateAccountTokens(userId, purpose) {
  990. const cleanPurpose = String(purpose || '').trim();
  991. if (!userId || !cleanPurpose) return 0;
  992. const result = requireDb()
  993. .prepare('UPDATE account_tokens SET used_at = ? WHERE user_id = ? AND purpose = ? AND used_at IS NULL')
  994. .run(now(), userId, cleanPurpose);
  995. return result.changes;
  996. }
  997. export function listDnsCredentials(userId) {
  998. return requireDb()
  999. .prepare('SELECT * FROM dns_credentials WHERE user_id = ? ORDER BY created_at DESC')
  1000. .all(userId)
  1001. .map(publicDnsCredential);
  1002. }
  1003. export function getDnsCredential(id, userId, { includeCredentials = false } = {}) {
  1004. const row = requireDb()
  1005. .prepare('SELECT * FROM dns_credentials WHERE id = ? AND user_id = ?')
  1006. .get(id, userId);
  1007. if (!row) return null;
  1008. const publicRow = publicDnsCredential(row);
  1009. if (!includeCredentials) return publicRow;
  1010. return {
  1011. ...publicRow,
  1012. credentials: safeJson(decryptSecret(row.credentials_secret), {})
  1013. };
  1014. }
  1015. export function saveDnsCredential(userId, credential) {
  1016. const provider = normalizeProvider(credential.provider);
  1017. if (!provider) throw new Error('DNS 服务商不支持。');
  1018. const name = String(credential.name || provider).trim();
  1019. const zoneName = String(credential.zoneName || credential.zone || '').trim().toLowerCase();
  1020. const defaultTtl = clampTtl(credential.defaultTtl);
  1021. const credentials = credential.credentials || pickCredentialFields(credential);
  1022. const updatedAt = now();
  1023. if (credential.id) {
  1024. const current = getDnsCredential(credential.id, userId, { includeCredentials: true });
  1025. if (!current) return null;
  1026. const nextCredentials = Object.keys(credentials).length ? credentials : current.credentials;
  1027. requireDb()
  1028. .prepare(`
  1029. UPDATE dns_credentials
  1030. SET name = ?, provider = ?, zone_name = ?, default_ttl = ?, credentials_secret = ?, updated_at = ?
  1031. WHERE id = ? AND user_id = ?
  1032. `)
  1033. .run(name, provider, zoneName, defaultTtl, encryptSecret(JSON.stringify(nextCredentials)), updatedAt, credential.id, userId);
  1034. return getDnsCredential(credential.id, userId);
  1035. }
  1036. const result = requireDb()
  1037. .prepare(`
  1038. INSERT INTO dns_credentials (user_id, name, provider, zone_name, default_ttl, credentials_secret, created_at, updated_at)
  1039. VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  1040. `)
  1041. .run(userId, name, provider, zoneName, defaultTtl, encryptSecret(JSON.stringify(credentials)), updatedAt, updatedAt);
  1042. return getDnsCredential(result.lastInsertRowid, userId);
  1043. }
  1044. export function deleteDnsCredential(id, userId) {
  1045. requireDb().prepare('UPDATE domains SET dns_credential_id = NULL WHERE dns_credential_id = ? AND user_id = ?').run(id, userId);
  1046. const result = requireDb().prepare('DELETE FROM dns_credentials WHERE id = ? AND user_id = ?').run(id, userId);
  1047. return result.changes > 0;
  1048. }
  1049. export function getSettings(defaults = {}) {
  1050. const rows = requireDb().prepare('SELECT * FROM app_settings').all();
  1051. const values = { ...defaults };
  1052. for (const row of rows) values[row.key] = row.value;
  1053. return values;
  1054. }
  1055. export function saveSettings(patch) {
  1056. const allowed = new Set([
  1057. 'appBaseUrl',
  1058. 'mailHostname',
  1059. 'sendingIp',
  1060. 'defaultSpfMechanisms',
  1061. 'dmarcPolicy',
  1062. 'dmarcRua',
  1063. 'sendRequiresVerified'
  1064. ]);
  1065. const updatedAt = now();
  1066. for (const [key, value] of Object.entries(patch)) {
  1067. if (!allowed.has(key)) continue;
  1068. saveAppSetting(key, String(value ?? ''), updatedAt);
  1069. }
  1070. return getSettings();
  1071. }
  1072. export function getSystemEmailSettings({ includeSecret = false } = {}) {
  1073. const rows = requireDb()
  1074. .prepare("SELECT key, value FROM app_settings WHERE key LIKE 'systemEmail.%'")
  1075. .all();
  1076. const values = Object.fromEntries(
  1077. rows.map((row) => [String(row.key).replace(/^systemEmail\./, ''), row.value])
  1078. );
  1079. const passwordSecret = values.passwordSecret || '';
  1080. const settings = {
  1081. host: values.host || '',
  1082. port: normalizePort(values.port, 587),
  1083. secure: values.secure === 'true',
  1084. username: values.username || '',
  1085. helo: values.helo || '',
  1086. fromEmail: values.fromEmail || '',
  1087. fromName: values.fromName || '',
  1088. testRecipient: values.testRecipient || '',
  1089. passwordSet: Boolean(passwordSecret)
  1090. };
  1091. if (includeSecret) settings.password = decryptSecret(passwordSecret);
  1092. return settings;
  1093. }
  1094. export function saveSystemEmailSettings(patch = {}) {
  1095. const current = getSystemEmailSettings({ includeSecret: true });
  1096. const next = {
  1097. host: patch.host ?? current.host,
  1098. port: patch.port ?? current.port,
  1099. secure: patch.secure ?? current.secure,
  1100. username: patch.username ?? current.username,
  1101. helo: patch.helo ?? current.helo,
  1102. fromEmail: patch.fromEmail ?? current.fromEmail,
  1103. fromName: patch.fromName ?? current.fromName,
  1104. testRecipient: patch.testRecipient ?? current.testRecipient
  1105. };
  1106. const passwordSecret = Object.hasOwn(patch, 'password') && String(patch.password || '')
  1107. ? encryptSecret(patch.password)
  1108. : requireDb()
  1109. .prepare("SELECT value FROM app_settings WHERE key = 'systemEmail.passwordSecret'")
  1110. .get()?.value || '';
  1111. const updatedAt = now();
  1112. const values = {
  1113. host: String(next.host || ''),
  1114. port: String(normalizePort(next.port, 587)),
  1115. secure: boolString(next.secure),
  1116. username: String(next.username || ''),
  1117. helo: String(next.helo || ''),
  1118. fromEmail: normalizeEmail(next.fromEmail),
  1119. fromName: String(next.fromName || ''),
  1120. testRecipient: normalizeEmail(next.testRecipient),
  1121. passwordSecret
  1122. };
  1123. for (const [key, value] of Object.entries(values)) {
  1124. saveAppSetting(`systemEmail.${key}`, value, updatedAt);
  1125. }
  1126. return getSystemEmailSettings();
  1127. }
  1128. function saveAppSetting(key, value, updatedAt = now()) {
  1129. requireDb()
  1130. .prepare(`
  1131. INSERT INTO app_settings (key, value, updated_at)
  1132. VALUES (?, ?, ?)
  1133. ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
  1134. `)
  1135. .run(key, String(value ?? ''), updatedAt);
  1136. }
  1137. function withTransaction(callback) {
  1138. const database = requireDb();
  1139. database.exec('BEGIN');
  1140. try {
  1141. const result = callback();
  1142. database.exec('COMMIT');
  1143. return result;
  1144. } catch (error) {
  1145. database.exec('ROLLBACK');
  1146. throw error;
  1147. }
  1148. }
  1149. function requireTransferTargetUser(targetUserId) {
  1150. const target = getUser(Number(targetUserId));
  1151. if (!target || target.status === 'disabled') throw new Error('目标用户不可用。');
  1152. return target;
  1153. }
  1154. function requireMergeUsers(sourceUserId, targetUserId) {
  1155. const source = getUser(Number(sourceUserId));
  1156. const target = requireTransferTargetUser(targetUserId);
  1157. if (!source) throw new Error('源用户不存在。');
  1158. if (source.id === target.id) throw new Error('源用户和目标用户不能相同。');
  1159. return { source, target };
  1160. }
  1161. function mergeResourcesForUser(userId) {
  1162. return {
  1163. domains: listDomains(userId),
  1164. dnsCredentials: listDnsCredentials(userId),
  1165. apiTokens: listApiTokens(userId),
  1166. sendEventCount: countRows('send_events', userId),
  1167. smtpCredential: getSmtpCredential(userId)
  1168. };
  1169. }
  1170. function countRows(table, userId) {
  1171. return Number(requireDb().prepare(`SELECT COUNT(*) AS count FROM ${mergeResourceTable(table)} WHERE user_id = ?`).get(userId).count || 0);
  1172. }
  1173. function moveRows(table, sourceUserId, targetUserId) {
  1174. const result = requireDb()
  1175. .prepare(`UPDATE ${mergeResourceTable(table)} SET user_id = ? WHERE user_id = ?`)
  1176. .run(targetUserId, sourceUserId);
  1177. return result.changes;
  1178. }
  1179. function mergeResourceTable(table) {
  1180. if (!['domains', 'dns_credentials', 'api_tokens', 'send_events', 'smtp_credentials'].includes(table)) {
  1181. throw new Error('资源类型不正确。');
  1182. }
  1183. return table;
  1184. }
  1185. function requireDomainRow(domainId) {
  1186. const domain = requireDb().prepare('SELECT * FROM domains WHERE id = ?').get(Number(domainId));
  1187. if (!domain) throw new Error('域名不存在。');
  1188. return domain;
  1189. }
  1190. function requireDnsCredentialRow(credentialId) {
  1191. const credential = requireDb().prepare('SELECT * FROM dns_credentials WHERE id = ?').get(Number(credentialId));
  1192. if (!credential) throw new Error('DNS 凭据不存在。');
  1193. return credential;
  1194. }
  1195. function normalizeDnsCredentialTransferMode(value) {
  1196. const mode = String(value || 'domain_only').trim();
  1197. return ['domain_only', 'with_dns_credential', 'clear_dns_credential'].includes(mode) ? mode : 'domain_only';
  1198. }
  1199. function uniquePositiveIds(values) {
  1200. return [...new Set((Array.isArray(values) ? values : [values])
  1201. .map((value) => Number(value))
  1202. .filter((value) => Number.isInteger(value) && value > 0))];
  1203. }
  1204. function migrateLegacySmtpTable() {
  1205. if (!tableExists('smtp_credentials') || columnExists('smtp_credentials', 'user_id')) return;
  1206. if (!tableExists('smtp_credentials_legacy')) {
  1207. requireDb().exec('ALTER TABLE smtp_credentials RENAME TO smtp_credentials_legacy;');
  1208. } else {
  1209. requireDb().exec('DROP TABLE smtp_credentials;');
  1210. }
  1211. }
  1212. function requireDb() {
  1213. if (!db) throw new Error('Database is not initialized.');
  1214. return db;
  1215. }
  1216. function tableExists(table) {
  1217. return Boolean(requireDb().prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table));
  1218. }
  1219. function columnExists(table, column) {
  1220. if (!tableExists(table)) return false;
  1221. return requireDb()
  1222. .prepare(`PRAGMA table_info(${table})`)
  1223. .all()
  1224. .some((row) => row.name === column);
  1225. }
  1226. function ensureColumn(table, column, definition) {
  1227. if (!columnExists(table, column)) requireDb().exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
  1228. }
  1229. function normalizeDkimPublicKeys() {
  1230. if (!tableExists('domains') || !columnExists('domains', 'dkim_private') || !columnExists('domains', 'dkim_public')) return;
  1231. const rows = requireDb().prepare('SELECT id, dkim_public, dkim_private FROM domains').all();
  1232. const update = requireDb().prepare('UPDATE domains SET dkim_public = ?, updated_at = ? WHERE id = ?');
  1233. for (const row of rows) {
  1234. if (!row.dkim_private) continue;
  1235. try {
  1236. const publicKey = dkimPublicFromPrivateKey(row.dkim_private);
  1237. if (publicKey && publicKey !== row.dkim_public) update.run(publicKey, now(), row.id);
  1238. } catch {
  1239. // Leave legacy or malformed rows untouched; rotating DKIM from the UI can repair them.
  1240. }
  1241. }
  1242. }
  1243. function normalizeSendEventQueueIds() {
  1244. if (!tableExists('send_events') || !columnExists('send_events', 'queue_id')) return;
  1245. const rows = requireDb()
  1246. .prepare("SELECT id, detail FROM send_events WHERE queue_id = '' OR queue_id IS NULL")
  1247. .all();
  1248. const update = requireDb().prepare('UPDATE send_events SET queue_id = ? WHERE id = ?');
  1249. for (const row of rows) {
  1250. const queueId = extractQueueIdFromText(row.detail);
  1251. if (queueId) update.run(queueId, row.id);
  1252. }
  1253. }
  1254. function publicUser(row) {
  1255. if (!row) return null;
  1256. return {
  1257. id: row.id,
  1258. username: row.username,
  1259. email: row.email,
  1260. role: row.role,
  1261. status: row.status,
  1262. createdAt: row.created_at,
  1263. updatedAt: row.updated_at
  1264. };
  1265. }
  1266. function privateUser(row) {
  1267. const user = publicUser(row);
  1268. return user ? { ...user, passwordHash: row.password_hash } : null;
  1269. }
  1270. function publicDomainRow(row) {
  1271. if (!row) return null;
  1272. return {
  1273. id: row.id,
  1274. userId: row.user_id,
  1275. dnsCredentialId: row.dns_credential_id,
  1276. domain: row.domain,
  1277. selector: row.selector,
  1278. verificationToken: row.verification_token,
  1279. dkimPublic: row.dkim_public,
  1280. senderHost: row.sender_host,
  1281. sendingIp: row.sending_ip,
  1282. spfExtra: row.spf_extra,
  1283. dmarcPolicy: row.dmarc_policy,
  1284. dmarcRua: row.dmarc_rua,
  1285. status: safeJson(row.status_json, {}),
  1286. createdAt: row.created_at,
  1287. updatedAt: row.updated_at
  1288. };
  1289. }
  1290. function privateDomainRow(row) {
  1291. const publicRow = publicDomainRow(row);
  1292. return publicRow ? { ...publicRow, dkimPrivate: row.dkim_private } : null;
  1293. }
  1294. function publicSmtpCredential(row, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
  1295. if (!row) return null;
  1296. const password = includePassword ? decryptSecret(row.password_secret) : '';
  1297. const passwordRecoverable = Boolean(row.password_secret && (password || decryptSecret(row.password_secret)));
  1298. return {
  1299. id: row.id,
  1300. userId: row.user_id,
  1301. username: row.username,
  1302. passwordSet: Boolean(row.password_hash),
  1303. passwordRecoverable,
  1304. ...(includePassword ? { password } : {}),
  1305. ...(includeHash ? { passwordHash: row.password_hash } : {}),
  1306. ...(includeSecret ? { passwordSecret: row.password_secret } : {}),
  1307. createdAt: row.created_at,
  1308. updatedAt: row.updated_at
  1309. };
  1310. }
  1311. function publicApiToken(row) {
  1312. if (!row) return null;
  1313. return {
  1314. id: row.id,
  1315. userId: row.user_id,
  1316. name: row.name,
  1317. tokenPrefix: row.token_prefix,
  1318. lastUsedAt: row.last_used_at,
  1319. createdAt: row.created_at
  1320. };
  1321. }
  1322. function getAccountTokenRow(id) {
  1323. return requireDb().prepare('SELECT * FROM account_tokens WHERE id = ?').get(id);
  1324. }
  1325. function publicAccountToken(row) {
  1326. if (!row) return null;
  1327. return {
  1328. id: row.id,
  1329. userId: row.user_id,
  1330. purpose: row.purpose,
  1331. expiresAt: row.expires_at,
  1332. usedAt: row.used_at,
  1333. createdAt: row.created_at
  1334. };
  1335. }
  1336. function publicDnsCredential(row) {
  1337. if (!row) return null;
  1338. return {
  1339. id: row.id,
  1340. userId: row.user_id,
  1341. name: row.name,
  1342. provider: row.provider,
  1343. zoneName: row.zone_name,
  1344. defaultTtl: row.default_ttl,
  1345. credentialSet: Boolean(row.credentials_secret),
  1346. createdAt: row.created_at,
  1347. updatedAt: row.updated_at
  1348. };
  1349. }
  1350. function publicAuditLog(row) {
  1351. if (!row) return null;
  1352. return {
  1353. id: row.id,
  1354. actorUserId: row.actor_user_id,
  1355. action: row.action,
  1356. targetType: row.target_type,
  1357. targetId: row.target_id,
  1358. targetUserId: row.target_user_id,
  1359. summary: safeJson(row.summary_json, {}),
  1360. createdAt: row.created_at
  1361. };
  1362. }
  1363. function normalizeUsername(value) {
  1364. const username = String(value || '').trim().toLowerCase();
  1365. return /^[a-z0-9][a-z0-9_.-]{2,31}$/.test(username) ? username : '';
  1366. }
  1367. function normalizeUserStatus(value) {
  1368. const status = String(value || '').trim();
  1369. if (!USER_STATUSES.has(status)) throw new Error('用户状态不正确。');
  1370. return status;
  1371. }
  1372. function normalizeAccountTokenPurpose(value) {
  1373. const purpose = String(value || '').trim();
  1374. if (!purpose) throw new Error('账号令牌用途不能为空。');
  1375. return purpose;
  1376. }
  1377. function normalizeEmail(value) {
  1378. const email = String(value || '').trim().toLowerCase();
  1379. return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : '';
  1380. }
  1381. function normalizePort(value, fallback) {
  1382. const port = Number(value);
  1383. return Number.isInteger(port) && port > 0 && port <= 65535 ? port : fallback;
  1384. }
  1385. function boolString(value) {
  1386. return value === true || String(value).toLowerCase() === 'true' ? 'true' : 'false';
  1387. }
  1388. function normalizeProvider(value) {
  1389. const provider = String(value || '').trim().toLowerCase();
  1390. return ['cloudflare', 'aliyun', 'dnspod'].includes(provider) ? provider : '';
  1391. }
  1392. function pickCredentialFields(source) {
  1393. const output = {};
  1394. for (const key of ['apiToken', 'zoneId', 'accessKeyId', 'accessKeySecret', 'secretId', 'secretKey']) {
  1395. if (source[key]) output[key] = String(source[key]).trim();
  1396. }
  1397. return output;
  1398. }
  1399. function clampTtl(value) {
  1400. const ttl = Number(value || 600);
  1401. if (!Number.isInteger(ttl) || ttl < 60) return 600;
  1402. if (ttl > 86400) return 86400;
  1403. return ttl;
  1404. }
  1405. function clampAnalyticsDays(value) {
  1406. const days = Number(value || 30);
  1407. if (!Number.isInteger(days) || days < 7) return 30;
  1408. if (days > 90) return 90;
  1409. return days;
  1410. }
  1411. function buildDayBuckets(days) {
  1412. const buckets = new Map();
  1413. const cursor = new Date();
  1414. cursor.setUTCHours(0, 0, 0, 0);
  1415. cursor.setUTCDate(cursor.getUTCDate() - (days - 1));
  1416. for (let index = 0; index < days; index += 1) {
  1417. const date = new Date(cursor);
  1418. date.setUTCDate(cursor.getUTCDate() + index);
  1419. const day = date.toISOString().slice(0, 10);
  1420. buckets.set(day, {
  1421. day,
  1422. total: 0,
  1423. queued: 0,
  1424. failed: 0,
  1425. recipients: 0
  1426. });
  1427. }
  1428. return buckets;
  1429. }
  1430. function safeJson(value, fallback) {
  1431. try {
  1432. return JSON.parse(value);
  1433. } catch {
  1434. return fallback;
  1435. }
  1436. }
  1437. function sanitizeAuditSummary(value, parentKey = '') {
  1438. if (Array.isArray(value)) return value.map((item) => sanitizeAuditSummary(item, parentKey));
  1439. if (!value || typeof value !== 'object') return value;
  1440. const hasSensitiveDescriptor = hasSensitiveAuditDescriptor(value);
  1441. const output = {};
  1442. for (const [key, child] of Object.entries(value)) {
  1443. if (auditSecretKeyPattern.test(key) && !isSafeAuditStateKey(key, child, parentKey)) continue;
  1444. if (hasSensitiveDescriptor && auditValueLikeKeyPattern.test(key)) continue;
  1445. output[key] = sanitizeAuditSummary(child, key);
  1446. }
  1447. return output;
  1448. }
  1449. function isSafeAuditStateKey(key, value, parentKey) {
  1450. return (key === 'passwordSet' && typeof value === 'boolean') ||
  1451. (parentKey === 'counts' && typeof value === 'number');
  1452. }
  1453. function hasSensitiveAuditDescriptor(value) {
  1454. return Object.entries(value).some(([key, child]) => (
  1455. auditDescriptorKeyPattern.test(key) && isSensitiveAuditDescriptorValue(child)
  1456. )) || Object.entries(value).some(([key, child]) => (
  1457. auditDescriptorWrapperKeyPattern.test(key) && hasDirectSensitiveAuditDescriptor(child)
  1458. ));
  1459. }
  1460. function hasDirectSensitiveAuditDescriptor(value) {
  1461. if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
  1462. return Object.entries(value).some(([key, child]) => (
  1463. auditDescriptorKeyPattern.test(key) && isSensitiveAuditDescriptorValue(child)
  1464. ));
  1465. }
  1466. function isSensitiveAuditDescriptorValue(value) {
  1467. if (Array.isArray(value)) return value.some(isSensitiveAuditDescriptorValue);
  1468. if (value && typeof value === 'object') return Object.values(value).some(isSensitiveAuditDescriptorValue);
  1469. return auditDescriptorValuePattern.test(String(value ?? ''));
  1470. }
  1471. function addAuditFilter(where, params, column, value) {
  1472. if (value === undefined) return;
  1473. if (value === null) {
  1474. where.push(`${column} IS NULL`);
  1475. return;
  1476. }
  1477. where.push(`${column} = ?`);
  1478. params.push(value);
  1479. }
  1480. function addAuditDateFilter(where, params, column, operator, value) {
  1481. if (value === undefined || value === null || value === '') return;
  1482. where.push(`${column} ${operator} ?`);
  1483. params.push(value);
  1484. }
  1485. function normalizeQueueId(value) {
  1486. return String(value || '').trim().toUpperCase();
  1487. }
  1488. function extractQueueIdFromText(value) {
  1489. return String(value || '').match(/\bqueued as\s+([A-Z0-9]{5,})\b/i)?.[1]?.toUpperCase() || '';
  1490. }
  1491. function normalizeDeliveryAttempt(attempt, queueId) {
  1492. return {
  1493. at: attempt?.at || now(),
  1494. source: attempt?.source || 'postfix',
  1495. queueId,
  1496. recipient: String(attempt?.recipient || '').toLowerCase(),
  1497. relay: String(attempt?.relay || ''),
  1498. dsn: String(attempt?.dsn || ''),
  1499. status: String(attempt?.status || 'unknown').toLowerCase(),
  1500. response: String(attempt?.response || ''),
  1501. raw: String(attempt?.raw || '')
  1502. };
  1503. }
  1504. function deliveryAttemptKey(attempt) {
  1505. return attempt.raw || [
  1506. attempt.queueId,
  1507. attempt.recipient,
  1508. attempt.status,
  1509. attempt.dsn,
  1510. attempt.response
  1511. ].join('|');
  1512. }
  1513. function deliveryStatusForEvent(recipients, attempts, currentStatus) {
  1514. const byRecipient = new Map();
  1515. for (const attempt of attempts) {
  1516. if (attempt.recipient) byRecipient.set(String(attempt.recipient).toLowerCase(), attempt.status);
  1517. }
  1518. const normalizedRecipients = recipients.map((recipient) => String(recipient || '').toLowerCase()).filter(Boolean);
  1519. const statuses = normalizedRecipients.map((recipient) => byRecipient.get(recipient)).filter(Boolean);
  1520. if (normalizedRecipients.length && statuses.length === normalizedRecipients.length && statuses.every((status) => status === 'sent')) {
  1521. return 'sent';
  1522. }
  1523. if (statuses.includes('deferred')) return 'deferred';
  1524. if (statuses.includes('bounced')) return 'bounced';
  1525. return currentStatus || attempts.at(-1)?.status || 'queued';
  1526. }
  1527. function deliveryAttemptDetail(attempt) {
  1528. const parts = [
  1529. attempt.status,
  1530. attempt.recipient ? `to ${attempt.recipient}` : '',
  1531. attempt.relay ? `via ${attempt.relay}` : '',
  1532. attempt.dsn ? `dsn=${attempt.dsn}` : ''
  1533. ].filter(Boolean);
  1534. return `${parts.join(' ')}${attempt.response ? `; ${attempt.response}` : ''}`;
  1535. }
  1536. function isDeliveryFailureStatus(status) {
  1537. return ['deferred', 'bounced', 'failed'].includes(String(status || '').toLowerCase());
  1538. }
  1539. function now() {
  1540. return new Date().toISOString();
  1541. }
  1542. function tokenHash(token) {
  1543. return crypto.createHash('sha256').update(String(token || '')).digest('hex');
  1544. }
  1545. function hashPassword(password) {
  1546. const salt = crypto.randomBytes(16).toString('hex');
  1547. const hash = crypto.scryptSync(String(password), salt, 64).toString('hex');
  1548. return `scrypt$${salt}$${hash}`;
  1549. }
  1550. function verifyPassword(password, stored) {
  1551. const [scheme, salt, hash] = String(stored || '').split('$');
  1552. if (scheme !== 'scrypt' || !salt || !hash) return false;
  1553. const actual = crypto.scryptSync(String(password), salt, 64).toString('hex');
  1554. return safeEqual(actual, hash);
  1555. }
  1556. function encryptSecret(value) {
  1557. if (!value) return '';
  1558. const iv = crypto.randomBytes(12);
  1559. const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv);
  1560. const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
  1561. return [
  1562. 'v1',
  1563. iv.toString('base64url'),
  1564. cipher.getAuthTag().toString('base64url'),
  1565. encrypted.toString('base64url')
  1566. ].join('$');
  1567. }
  1568. function decryptSecret(secret) {
  1569. const [version, ivRaw, tagRaw, encryptedRaw] = String(secret || '').split('$');
  1570. if (version !== 'v1' || !ivRaw || !tagRaw || !encryptedRaw) return '';
  1571. try {
  1572. const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivRaw, 'base64url'));
  1573. decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
  1574. return Buffer.concat([
  1575. decipher.update(Buffer.from(encryptedRaw, 'base64url')),
  1576. decipher.final()
  1577. ]).toString('utf8');
  1578. } catch {
  1579. return '';
  1580. }
  1581. }
  1582. function encryptionKey() {
  1583. return crypto
  1584. .createHash('sha256')
  1585. .update(secretKey || 'mailhub-local-secret')
  1586. .digest();
  1587. }
  1588. function safeEqual(actual, expected) {
  1589. const a = Buffer.from(String(actual || ''));
  1590. const b = Buffer.from(String(expected || ''));
  1591. if (a.length !== b.length) return false;
  1592. return crypto.timingSafeEqual(a, b);
  1593. }