server.js 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438
  1. import crypto from 'node:crypto';
  2. import { existsSync, readFileSync, statSync } from 'node:fs';
  3. import { readFile } from 'node:fs/promises';
  4. import http from 'node:http';
  5. import path from 'node:path';
  6. import { fileURLToPath, domainToASCII } from 'node:url';
  7. import {
  8. authenticateUser,
  9. approveUser,
  10. claimLegacyData,
  11. createApiToken,
  12. createAccountToken,
  13. createDomain,
  14. createUserWithAccountToken,
  15. consumeAccountToken,
  16. deleteApiToken,
  17. deleteDnsCredential,
  18. deleteDomain,
  19. deleteSmtpCredential,
  20. getAdminResourceInventory,
  21. getAdminUser,
  22. getDnsCredential,
  23. getDomain,
  24. getDomainByName,
  25. getSendAnalytics,
  26. getSettings,
  27. getDefaultSmtpRelay,
  28. getSmtpRelay,
  29. getSmtpCredential,
  30. getSystemEmailSettings,
  31. getUser,
  32. getUserByLogin,
  33. initDatabase,
  34. invalidateAccountTokens,
  35. listApiTokens,
  36. listAuditLogs,
  37. listDnsCredentials,
  38. listDomains,
  39. listSendEvents,
  40. listSmtpCredentials,
  41. listSmtpRelays,
  42. listUsersWithResourceCounts,
  43. logAudit,
  44. logSendEvent,
  45. markUserEmailVerified,
  46. previewUserMerge,
  47. saveDnsCredential,
  48. saveDomainStatus,
  49. saveSettings,
  50. saveSmtpRelay,
  51. saveSmtpCredential,
  52. saveSystemEmailSettings,
  53. seedAdminUser,
  54. seedSmtpCredential,
  55. deleteSmtpRelay,
  56. transferApiTokens,
  57. transferDnsCredential,
  58. transferDomain,
  59. updateDkim,
  60. updateDomain,
  61. updateUser,
  62. executeUserMerge,
  63. verifyApiToken,
  64. verifyUserCredentials
  65. } from './db.js';
  66. import { applyDnsSetup, testDnsCredential } from './dns-providers.js';
  67. import { startDnsAutoChecker } from './dns-auto-checker.js';
  68. import { startPostfixDeliveryTracker } from './delivery-tracker.js';
  69. import { buildDnsGuide, buildSystemDnsChecks } from './dns-guide.js';
  70. import { createDkimKeyPair } from './dkim.js';
  71. import {
  72. buildMessage,
  73. domainFromAddress,
  74. extractAddress,
  75. parseAddressList,
  76. sendViaSmtp,
  77. signMessageForDomain
  78. } from './mailer.js';
  79. import {
  80. parseSubmissionListeners,
  81. publicSubmissionListeners,
  82. startSubmissionServer
  83. } from './submission.js';
  84. import {
  85. buildPasswordResetEmail,
  86. buildVerificationEmail,
  87. sendSystemEmail
  88. } from './system-mail.js';
  89. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  90. loadDotEnv();
  91. const envConfig = {
  92. port: Number(process.env.PORT || 3000),
  93. dataDir: process.env.DATA_DIR || path.join(process.cwd(), 'data'),
  94. adminUser: process.env.ADMIN_USER || 'admin',
  95. adminEmail: process.env.ADMIN_EMAIL || `${process.env.ADMIN_USER || 'admin'}@mailhub.local`,
  96. adminPassword: process.env.ADMIN_PASSWORD || 'change-this-admin-password',
  97. legacyApiToken: process.env.API_TOKEN || '',
  98. smtpHost: process.env.SMTP_HOST || '',
  99. smtpPort: Number(process.env.SMTP_PORT || 25),
  100. smtpSecure: String(process.env.SMTP_SECURE || '').toLowerCase() === 'true',
  101. smtpUser: process.env.SMTP_USERNAME || '',
  102. smtpPassword: process.env.SMTP_PASSWORD || '',
  103. smtpHelo: process.env.SMTP_HELO || process.env.MAIL_HOSTNAME || 'mailhub.local',
  104. postfixLogFile: process.env.POSTFIX_LOG_FILE || path.join(process.env.DATA_DIR || path.join(process.cwd(), 'data'), 'postfix-logs', 'mail.log'),
  105. postfixLogPollIntervalMs: Number(process.env.POSTFIX_LOG_POLL_INTERVAL_MS || 5000),
  106. deliveryTrackingEnabled: String(process.env.DELIVERY_TRACKING_ENABLED || 'true').toLowerCase() !== 'false',
  107. dnsAutoCheckEnabled: String(process.env.DNS_AUTO_CHECK_ENABLED || 'true').toLowerCase() !== 'false',
  108. dnsAutoCheckIntervalMs: Number(process.env.DNS_AUTO_CHECK_INTERVAL_MS || 60000),
  109. dnsAutoCheckLimit: Number(process.env.DNS_AUTO_CHECK_LIMIT || 25),
  110. submissionEnabled: String(process.env.SUBMISSION_ENABLED || 'true').toLowerCase() !== 'false',
  111. submissionHost: process.env.SUBMISSION_HOST || process.env.APP_BASE_URL?.replace(/^https?:\/\//, '') || 'localhost',
  112. submissionListeners: parseSubmissionListeners(process.env.SUBMISSION_PORTS),
  113. submissionUsername: process.env.SUBMISSION_USERNAME || '',
  114. submissionPassword: process.env.SUBMISSION_PASSWORD || '',
  115. submissionAllowInsecureAuth: String(process.env.SUBMISSION_ALLOW_INSECURE_AUTH || '').toLowerCase() === 'true',
  116. submissionTlsCert: process.env.SUBMISSION_TLS_CERT || '',
  117. submissionTlsKey: process.env.SUBMISSION_TLS_KEY || '',
  118. sessionSecret: process.env.SESSION_SECRET || crypto
  119. .createHash('sha256')
  120. .update(`${process.env.ADMIN_PASSWORD || 'change-this-admin-password'}:${process.env.API_TOKEN || ''}`)
  121. .digest('hex')
  122. };
  123. const defaultSettings = {
  124. appBaseUrl: process.env.APP_BASE_URL || 'http://127.0.0.1:3000',
  125. mailHostname: process.env.MAIL_HOSTNAME || 'mailhub.local',
  126. sendingIp: process.env.SENDING_IP || '',
  127. defaultSpfMechanisms: process.env.DEFAULT_SPF_MECHANISMS || 'include:spf.mailjet.com',
  128. dmarcPolicy: process.env.DMARC_POLICY || 'none',
  129. dmarcRua: process.env.DMARC_RUA || '',
  130. sendRequiresVerified: String(process.env.SEND_REQUIRES_VERIFIED || '').toLowerCase() === 'true' ? 'true' : 'false'
  131. };
  132. const emailVerificationPurpose = 'email_verification';
  133. const passwordResetPurpose = 'password_reset';
  134. initDatabase(envConfig.dataDir, envConfig.sessionSecret);
  135. const admin = seedAdminUser({
  136. username: envConfig.adminUser,
  137. email: envConfig.adminEmail,
  138. password: envConfig.adminPassword
  139. });
  140. claimLegacyData(admin.id);
  141. seedSmtpCredential(admin.id, envConfig.submissionUsername, envConfig.submissionPassword);
  142. startPostfixDeliveryTracker({
  143. enabled: envConfig.deliveryTrackingEnabled,
  144. logFile: envConfig.postfixLogFile,
  145. pollIntervalMs: envConfig.postfixLogPollIntervalMs
  146. });
  147. startDnsAutoChecker({
  148. enabled: envConfig.dnsAutoCheckEnabled,
  149. intervalMs: envConfig.dnsAutoCheckIntervalMs,
  150. limit: envConfig.dnsAutoCheckLimit
  151. });
  152. const server = http.createServer(async (req, res) => {
  153. try {
  154. setSecurityHeaders(res);
  155. if (req.method === 'OPTIONS') return handleOptions(res);
  156. const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
  157. if (url.pathname === '/healthz') return sendJson(res, 200, { ok: true });
  158. if (req.method === 'POST' && (url.pathname === '/api/register' || url.pathname === '/register')) return await handleRegister(req, res);
  159. if (req.method === 'POST' && (url.pathname === '/api/login' || url.pathname === '/login')) return await handleLogin(req, res);
  160. if (req.method === 'POST' && url.pathname === '/api/logout') return handleLogout(res);
  161. if (url.pathname === '/api/auth/verify-email') return await handleVerifyEmail(req, res, url);
  162. if (req.method === 'POST' && url.pathname === '/api/auth/resend-verification') return await handleResendVerification(req, res);
  163. if (req.method === 'POST' && url.pathname === '/api/auth/forgot-password') return await handleForgotPassword(req, res);
  164. if (req.method === 'POST' && url.pathname === '/api/auth/reset-password') return await handleResetPassword(req, res);
  165. const user = getRequestUser(req, url.pathname);
  166. if (isLoginAsset(url.pathname)) {
  167. if ((url.pathname === '/login' || url.pathname === '/register') && user) return redirect(res, '/');
  168. return await serveStatic(req, res, url);
  169. }
  170. if (!user) {
  171. if (url.pathname.startsWith('/api/')) return sendJson(res, 401, { error: 'Authentication required.' });
  172. return redirect(res, '/login');
  173. }
  174. if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url, user);
  175. return await serveStatic(req, res, url);
  176. } catch (error) {
  177. console.error(error);
  178. return sendJson(res, 500, { error: error.message || 'Internal server error.' });
  179. }
  180. });
  181. server.listen(envConfig.port, '0.0.0.0', () => {
  182. console.log(`MailHub listening on 0.0.0.0:${envConfig.port}`);
  183. });
  184. startSubmissionServer({
  185. enabled: envConfig.submissionEnabled,
  186. listeners: envConfig.submissionListeners,
  187. hostname: envConfig.submissionHost,
  188. allowInsecureAuth: envConfig.submissionAllowInsecureAuth,
  189. tlsCertPath: envConfig.submissionTlsCert,
  190. tlsKeyPath: envConfig.submissionTlsKey,
  191. relayHost: envConfig.smtpHost,
  192. relayPort: envConfig.smtpPort,
  193. relaySecure: envConfig.smtpSecure,
  194. relayUsername: envConfig.smtpUser,
  195. relayPassword: envConfig.smtpPassword,
  196. relayHelo: envConfig.smtpHelo
  197. });
  198. async function handleApi(req, res, url, user) {
  199. const method = req.method || 'GET';
  200. const pathname = url.pathname;
  201. if (method === 'GET' && pathname === '/api/me') {
  202. return sendJson(res, 200, { user });
  203. }
  204. if (method === 'GET' && pathname === '/api/config') {
  205. return sendJson(res, 200, publicConfig(user));
  206. }
  207. if (method === 'GET' && pathname === '/api/domains') {
  208. return sendJson(res, 200, { domains: listDomains(user.id) });
  209. }
  210. if (method === 'POST' && pathname === '/api/domains') {
  211. const body = await readJson(req);
  212. const settings = runtimeSettings();
  213. const domain = normalizeDomain(body.domain);
  214. if (!domain) return sendJson(res, 400, { error: '域名格式不正确。' });
  215. const selector = normalizeSelector(body.selector || defaultSelector());
  216. if (!selector) return sendJson(res, 400, { error: 'DKIM selector 格式不正确。' });
  217. const dnsCredentialId = Number(body.dnsCredentialId || 0) || null;
  218. if (dnsCredentialId && !getDnsCredential(dnsCredentialId, user.id)) {
  219. return sendJson(res, 400, { error: 'DNS 凭据不存在。' });
  220. }
  221. const smtpRelayId = Number(body.smtpRelayId || 0) || null;
  222. if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
  223. return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
  224. }
  225. const keys = createDkimKeyPair();
  226. try {
  227. const row = createDomain(user.id, {
  228. domain,
  229. selector,
  230. dnsCredentialId,
  231. smtpRelayId,
  232. verificationToken: crypto.randomBytes(18).toString('hex'),
  233. dkimPublic: keys.publicKey,
  234. dkimPrivate: keys.privateKey,
  235. senderHost: normalizeHostname(body.senderHost || settings.mailHostname),
  236. sendingIp: String(body.sendingIp || settings.sendingIp).trim(),
  237. spfExtra: String(body.spfExtra ?? settings.defaultSpfMechanisms).trim(),
  238. dmarcPolicy: normalizeDmarcPolicy(body.dmarcPolicy || settings.dmarcPolicy),
  239. dmarcRua: String(body.dmarcRua ?? settings.dmarcRua).trim()
  240. });
  241. return sendJson(res, 201, { domain: row });
  242. } catch (error) {
  243. if (isUniqueError(error)) return sendJson(res, 409, { error: '该域名已被添加。' });
  244. throw error;
  245. }
  246. }
  247. if (method === 'GET' && pathname === '/api/events') {
  248. return sendJson(res, 200, { events: listSendEvents(user.id) });
  249. }
  250. if (method === 'GET' && pathname === '/api/analytics') {
  251. return sendJson(res, 200, { analytics: getSendAnalytics(user.id, { days: Number(url.searchParams.get('days') || 7) }) });
  252. }
  253. if (method === 'GET' && pathname === '/api/smtp-credential') {
  254. return sendJson(res, 200, { credential: getSmtpCredential(user.id, { includePassword: true }) });
  255. }
  256. if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && pathname === '/api/smtp-credential') {
  257. const body = await readJson(req);
  258. try {
  259. const current = getSmtpCredential(user.id);
  260. saveSmtpCredential(user.id, {
  261. id: current?.id || null,
  262. username: String(body.username || '').trim(),
  263. password: String(body.password || '')
  264. });
  265. } catch (error) {
  266. if (isUniqueError(error)) return sendJson(res, 409, { error: 'SMTP 用户名已被占用。' });
  267. throw error;
  268. }
  269. return sendJson(res, 200, { credential: getSmtpCredential(user.id, { includePassword: true }) });
  270. }
  271. if (method === 'GET' && pathname === '/api/smtp-credentials') {
  272. return sendJson(res, 200, { credentials: listSmtpCredentials(user.id, { includePassword: true }) });
  273. }
  274. if (method === 'POST' && pathname === '/api/smtp-credentials') {
  275. const body = await readJson(req);
  276. try {
  277. const credential = saveSmtpCredential(user.id, {
  278. username: String(body.username || '').trim(),
  279. password: String(body.password || '')
  280. });
  281. return sendJson(res, 201, { credential: getSmtpCredential(credential.id, user.id, { includePassword: true }) });
  282. } catch (error) {
  283. if (isUniqueError(error)) return sendJson(res, 409, { error: 'SMTP 用户名已被占用。' });
  284. throw error;
  285. }
  286. }
  287. const smtpCredentialMatch = pathname.match(/^\/api\/smtp-credentials\/(\d+)$/);
  288. if (smtpCredentialMatch) {
  289. const id = Number(smtpCredentialMatch[1]);
  290. if (method === 'GET') {
  291. const credential = getSmtpCredential(id, user.id, { includePassword: true });
  292. return sendJson(res, credential ? 200 : 404, { credential });
  293. }
  294. if (method === 'PATCH' || method === 'PUT') {
  295. const body = await readJson(req);
  296. try {
  297. const credential = saveSmtpCredential(user.id, {
  298. id,
  299. username: String(body.username || '').trim(),
  300. password: String(body.password || '')
  301. });
  302. return sendJson(res, credential ? 200 : 404, {
  303. credential: credential ? getSmtpCredential(credential.id, user.id, { includePassword: true }) : null
  304. });
  305. } catch (error) {
  306. if (isUniqueError(error)) return sendJson(res, 409, { error: 'SMTP 用户名已被占用。' });
  307. throw error;
  308. }
  309. }
  310. if (method === 'DELETE') {
  311. const deleted = deleteSmtpCredential(id, user.id);
  312. return sendJson(res, deleted ? 200 : 404, { deleted });
  313. }
  314. }
  315. if (method === 'GET' && pathname === '/api/smtp-relays') {
  316. return sendJson(res, 200, { relays: listSmtpRelays(user.id) });
  317. }
  318. if (method === 'POST' && pathname === '/api/smtp-relays') {
  319. const body = await readJson(req);
  320. const relay = saveSmtpRelay(user.id, smtpRelayPatch(body));
  321. return sendJson(res, 201, { relay });
  322. }
  323. const smtpRelayMatch = pathname.match(/^\/api\/smtp-relays\/(\d+)$/);
  324. if (smtpRelayMatch) {
  325. const id = Number(smtpRelayMatch[1]);
  326. if (method === 'GET') {
  327. const relay = getSmtpRelay(id, user.id, { includePassword: true });
  328. return sendJson(res, relay ? 200 : 404, { relay });
  329. }
  330. if (method === 'PATCH' || method === 'PUT') {
  331. const body = await readJson(req);
  332. const relay = saveSmtpRelay(user.id, { ...smtpRelayPatch(body), id });
  333. return sendJson(res, relay ? 200 : 404, { relay });
  334. }
  335. if (method === 'DELETE') {
  336. const deleted = deleteSmtpRelay(id, user.id);
  337. return sendJson(res, deleted ? 200 : 404, { deleted });
  338. }
  339. }
  340. if (method === 'POST' && pathname === '/api/send') {
  341. const body = await readJson(req);
  342. const smtpRelayId = Number(body.smtpRelayId || 0) || null;
  343. if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
  344. return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
  345. }
  346. const result = await sendMailFromBody(body, user);
  347. return sendJson(res, 202, result);
  348. }
  349. if (method === 'GET' && pathname === '/api/api-tokens') {
  350. return sendJson(res, 200, { tokens: listApiTokens(user.id) });
  351. }
  352. if (method === 'POST' && pathname === '/api/api-tokens') {
  353. const body = await readJson(req);
  354. return sendJson(res, 201, { token: createApiToken(user.id, body.name) });
  355. }
  356. const tokenMatch = pathname.match(/^\/api\/api-tokens\/(\d+)$/);
  357. if (tokenMatch && method === 'DELETE') {
  358. const deleted = deleteApiToken(Number(tokenMatch[1]), user.id);
  359. return sendJson(res, deleted ? 200 : 404, { deleted });
  360. }
  361. if (method === 'GET' && pathname === '/api/dns-credentials') {
  362. return sendJson(res, 200, { credentials: listDnsCredentials(user.id) });
  363. }
  364. if (method === 'POST' && pathname === '/api/dns-credentials') {
  365. const body = await readJson(req);
  366. const credential = saveDnsCredential(user.id, body);
  367. return sendJson(res, 201, { credential });
  368. }
  369. const dnsMatch = pathname.match(/^\/api\/dns-credentials\/(\d+)(?:\/([a-z-]+))?$/);
  370. if (dnsMatch) {
  371. const id = Number(dnsMatch[1]);
  372. const action = dnsMatch[2] || '';
  373. if ((method === 'PUT' || method === 'PATCH') && !action) {
  374. const body = await readJson(req);
  375. const credential = saveDnsCredential(user.id, { ...body, id });
  376. return sendJson(res, credential ? 200 : 404, { credential });
  377. }
  378. if (method === 'DELETE' && !action) {
  379. const deleted = deleteDnsCredential(id, user.id);
  380. return sendJson(res, deleted ? 200 : 404, { deleted });
  381. }
  382. if (method === 'POST' && action === 'test') {
  383. const credential = getDnsCredential(id, user.id, { includeCredentials: true });
  384. if (!credential) return sendJson(res, 404, { error: 'DNS 凭据不存在。' });
  385. const result = await testDnsCredential(credential);
  386. return sendJson(res, result.ok ? 200 : 400, result);
  387. }
  388. }
  389. if (pathname.startsWith('/api/admin/')) {
  390. return await handleAdminApi(req, res, url, user);
  391. }
  392. const domainMatch = pathname.match(/^\/api\/domains\/(\d+)(?:\/([a-z-]+))?$/);
  393. if (domainMatch) {
  394. const id = Number(domainMatch[1]);
  395. const action = domainMatch[2] || '';
  396. if (method === 'GET' && !action) {
  397. const domain = getDomain(id, { userId: user.id });
  398. if (!domain) return sendJson(res, 404, { error: '域名不存在。' });
  399. return sendJson(res, 200, { domain });
  400. }
  401. if (method === 'PATCH' && !action) {
  402. const body = await readJson(req);
  403. const dnsCredentialId = body.dnsCredentialId !== undefined ? Number(body.dnsCredentialId || 0) || null : undefined;
  404. if (dnsCredentialId && !getDnsCredential(dnsCredentialId, user.id)) {
  405. return sendJson(res, 400, { error: 'DNS 凭据不存在。' });
  406. }
  407. const smtpRelayId = body.smtpRelayId !== undefined ? Number(body.smtpRelayId || 0) || null : undefined;
  408. if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
  409. return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
  410. }
  411. const row = updateDomain(id, user.id, {
  412. selector: body.selector ? normalizeSelector(body.selector) : undefined,
  413. dnsCredentialId,
  414. smtpRelayId,
  415. senderHost: body.senderHost ? normalizeHostname(body.senderHost) : undefined,
  416. sendingIp: body.sendingIp !== undefined ? String(body.sendingIp).trim() : undefined,
  417. spfExtra: body.spfExtra !== undefined ? String(body.spfExtra).trim() : undefined,
  418. dmarcPolicy: body.dmarcPolicy ? normalizeDmarcPolicy(body.dmarcPolicy) : undefined,
  419. dmarcRua: body.dmarcRua !== undefined ? String(body.dmarcRua).trim() : undefined
  420. });
  421. if (!row) return sendJson(res, 404, { error: '域名不存在。' });
  422. return sendJson(res, 200, { domain: row });
  423. }
  424. if (method === 'DELETE' && !action) {
  425. const deleted = deleteDomain(id, user.id);
  426. return sendJson(res, deleted ? 200 : 404, { deleted });
  427. }
  428. if (method === 'POST' && action === 'check') {
  429. const row = getDomain(id, { userId: user.id });
  430. if (!row) return sendJson(res, 404, { error: '域名不存在。' });
  431. const guide = await buildDnsGuide(row);
  432. saveDomainStatus(id, user.id, guide);
  433. return sendJson(res, 200, { guide, domain: getDomain(id, { userId: user.id }) });
  434. }
  435. if (method === 'POST' && action === 'apply-dns') {
  436. const row = getDomain(id, { userId: user.id, includePrivate: true });
  437. if (!row) return sendJson(res, 404, { error: '域名不存在。' });
  438. const credentialId = Number(row.dnsCredentialId || 0);
  439. const credential = credentialId ? getDnsCredential(credentialId, user.id, { includeCredentials: true }) : null;
  440. if (!credential) return sendJson(res, 400, { error: '请先为该域名绑定 DNS API 凭据。' });
  441. const guide = await buildDnsGuide(row);
  442. const applyResult = await applyDnsSetup(row, credential, guide);
  443. const checkedGuide = await buildDnsGuideAfterApply(row, applyResult);
  444. checkedGuide.apply = applyResult;
  445. saveDomainStatus(id, user.id, checkedGuide);
  446. return sendJson(res, applyResult.ok ? 200 : 207, {
  447. apply: applyResult,
  448. guide: checkedGuide,
  449. domain: getDomain(id, { userId: user.id })
  450. });
  451. }
  452. if (method === 'POST' && action === 'rotate-dkim') {
  453. const row = getDomain(id, { userId: user.id });
  454. if (!row) return sendJson(res, 404, { error: '域名不存在。' });
  455. const body = await readJson(req).catch(() => ({}));
  456. const selector = normalizeSelector(body.selector || defaultSelector());
  457. const next = updateDkim(id, user.id, createDkimKeyPair(), selector);
  458. return sendJson(res, 200, { domain: next });
  459. }
  460. if (method === 'POST' && action === 'test-send') {
  461. const row = getDomain(id, { userId: user.id });
  462. if (!row) return sendJson(res, 404, { error: '域名不存在。' });
  463. const body = await readJson(req);
  464. const smtpRelayId = Number(body.smtpRelayId || 0) || null;
  465. if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
  466. return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
  467. }
  468. const from = body.from || `noreply@${row.domain}`;
  469. const result = await sendMailFromBody({
  470. from,
  471. to: body.to,
  472. subject: body.subject || `MailHub test for ${row.domain}`,
  473. text: body.text || `This is a MailHub test message from ${row.domain}.`,
  474. smtpRelayId: body.smtpRelayId
  475. }, user);
  476. return sendJson(res, 202, result);
  477. }
  478. }
  479. return sendJson(res, 404, { error: 'Not found.' });
  480. }
  481. async function handleAdminApi(req, res, url, user) {
  482. const method = req.method || 'GET';
  483. const pathname = url.pathname;
  484. if (!pathname.startsWith('/api/admin/')) return null;
  485. if (user.role !== 'admin') return sendJson(res, 403, { error: '需要管理员权限。' });
  486. if (method === 'GET' && pathname === '/api/admin/settings') {
  487. return sendJson(res, 200, { settings: await adminRuntimeSettings() });
  488. }
  489. if (method === 'GET' && pathname === '/api/admin/system-email') {
  490. return sendJson(res, 200, { settings: getSystemEmailSettings() });
  491. }
  492. if (method === 'GET' && pathname === '/api/admin/audit-logs') {
  493. return sendJson(res, 200, { logs: listAuditLogs(adminAuditFilters(url.searchParams)) });
  494. }
  495. if (method === 'GET' && pathname === '/api/admin/resources') {
  496. return sendJson(res, 200, { inventory: getAdminResourceInventory() });
  497. }
  498. const transferDomainMatch = pathname.match(/^\/api\/admin\/resources\/domains\/(\d+)\/transfer$/);
  499. if (transferDomainMatch && method === 'POST') {
  500. const body = await readJson(req);
  501. try {
  502. const domain = transferDomain({
  503. actorUserId: user.id,
  504. domainId: Number(transferDomainMatch[1]),
  505. targetUserId: body.targetUserId,
  506. dnsCredentialMode: body.dnsCredentialMode
  507. });
  508. return sendJson(res, 200, { domain });
  509. } catch (error) {
  510. return sendAdminTransferError(res, error);
  511. }
  512. }
  513. const transferDnsCredentialMatch = pathname.match(/^\/api\/admin\/resources\/dns-credentials\/(\d+)\/transfer$/);
  514. if (transferDnsCredentialMatch && method === 'POST') {
  515. const body = await readJson(req);
  516. try {
  517. const credential = transferDnsCredential({
  518. actorUserId: user.id,
  519. credentialId: Number(transferDnsCredentialMatch[1]),
  520. targetUserId: body.targetUserId
  521. });
  522. return sendJson(res, 200, { credential });
  523. } catch (error) {
  524. return sendAdminTransferError(res, error);
  525. }
  526. }
  527. if (method === 'POST' && pathname === '/api/admin/resources/api-tokens/transfer') {
  528. const body = await readJson(req);
  529. try {
  530. const tokens = transferApiTokens({
  531. actorUserId: user.id,
  532. tokenIds: body.tokenIds,
  533. targetUserId: body.targetUserId
  534. });
  535. return sendJson(res, 200, { tokens });
  536. } catch (error) {
  537. return sendAdminTransferError(res, error);
  538. }
  539. }
  540. if (method === 'POST' && pathname === '/api/admin/migrations/user-merge/preview') {
  541. const body = await readJson(req);
  542. try {
  543. const preview = previewUserMerge({
  544. sourceUserId: body.sourceUserId,
  545. targetUserId: body.targetUserId
  546. });
  547. return sendJson(res, 200, { preview });
  548. } catch (error) {
  549. return sendAdminMigrationError(res, error);
  550. }
  551. }
  552. if (method === 'POST' && pathname === '/api/admin/migrations/user-merge/execute') {
  553. const body = await readJson(req);
  554. try {
  555. const result = executeUserMerge({
  556. actorUserId: user.id,
  557. sourceUserId: body.sourceUserId,
  558. targetUserId: body.targetUserId,
  559. options: body.options,
  560. confirmation: body.confirmation
  561. });
  562. return sendJson(res, 200, { result });
  563. } catch (error) {
  564. return sendAdminMigrationError(res, error);
  565. }
  566. }
  567. if ((method === 'PATCH' || method === 'PUT') && pathname === '/api/admin/system-email') {
  568. const body = await readJson(req);
  569. const settings = saveSystemEmailSettings({
  570. host: body.host,
  571. port: body.port,
  572. secure: body.secure,
  573. username: body.username,
  574. password: body.password,
  575. helo: body.helo,
  576. fromEmail: body.fromEmail,
  577. fromName: body.fromName,
  578. testRecipient: body.testRecipient
  579. });
  580. logAudit({
  581. actorUserId: user.id,
  582. action: 'admin.update_system_email',
  583. targetType: 'system_email',
  584. targetId: 'default',
  585. summary: settings
  586. });
  587. return sendJson(res, 200, { settings });
  588. }
  589. if (method === 'POST' && pathname === '/api/admin/system-email/test') {
  590. const body = await readJson(req).catch(() => ({}));
  591. const settings = systemMailSettingsForSend();
  592. const to = extractAddress(body.to || settings.testRecipient);
  593. if (!to) return sendJson(res, 400, { error: '测试收件人地址格式不正确。' });
  594. const result = await sendSystemEmail(settings, {
  595. to,
  596. subject: 'MailHub 系统邮件测试',
  597. text: '这是一封 MailHub 系统邮件测试。'
  598. });
  599. logAudit({
  600. actorUserId: user.id,
  601. action: 'admin.test_system_email',
  602. targetType: 'system_email',
  603. targetId: 'default',
  604. summary: {
  605. to,
  606. ok: result.ok,
  607. message: result.message,
  608. queueId: result.queueId
  609. }
  610. });
  611. return sendJson(res, result.ok ? 202 : 502, { result });
  612. }
  613. if ((method === 'PATCH' || method === 'PUT') && pathname === '/api/admin/settings') {
  614. const body = await readJson(req);
  615. saveSettings({
  616. appBaseUrl: body.appBaseUrl,
  617. mailHostname: body.mailHostname,
  618. sendingIp: body.sendingIp,
  619. defaultSpfMechanisms: body.defaultSpfMechanisms,
  620. dmarcPolicy: normalizeDmarcPolicy(body.dmarcPolicy),
  621. dmarcRua: body.dmarcRua,
  622. sendRequiresVerified: boolString(body.sendRequiresVerified)
  623. });
  624. return sendJson(res, 200, { settings: await adminRuntimeSettings() });
  625. }
  626. if (method === 'GET' && pathname === '/api/admin/users') {
  627. return sendJson(res, 200, { users: listUsersWithResourceCounts() });
  628. }
  629. const approveMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/approve$/);
  630. if (approveMatch && method === 'POST') {
  631. const current = getUser(Number(approveMatch[1]));
  632. if (!current) return sendJson(res, 404, { error: '用户不存在。' });
  633. if (current.status === 'pending_email') return sendJson(res, 400, { error: '用户尚未验证邮箱。' });
  634. if (current.status !== 'pending_review') return sendJson(res, 400, { error: '只能审批等待审核的用户。' });
  635. const target = approveUser(current.id);
  636. if (!target) return sendJson(res, 404, { error: '用户不存在。' });
  637. logAudit({
  638. actorUserId: user.id,
  639. action: 'admin.approve_user',
  640. targetType: 'user',
  641. targetId: String(target.id),
  642. targetUserId: target.id,
  643. summary: {
  644. username: target.username,
  645. status: target.status
  646. }
  647. });
  648. return sendJson(res, 200, { user: target });
  649. }
  650. const resendVerificationMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/resend-verification$/);
  651. if (resendVerificationMatch && method === 'POST') {
  652. const target = getUser(Number(resendVerificationMatch[1]));
  653. if (!target) return sendJson(res, 404, { error: '用户不存在。' });
  654. if (target.status !== 'pending_email') return sendJson(res, 400, { error: '用户不需要重新发送验证邮件。' });
  655. const result = await createAndSendVerificationEmail(target);
  656. logAudit({
  657. actorUserId: user.id,
  658. action: 'admin.resend_verification',
  659. targetType: 'user',
  660. targetId: String(target.id),
  661. targetUserId: target.id,
  662. summary: {
  663. username: target.username,
  664. email: target.email,
  665. verificationEmailSent: result.ok,
  666. message: result.message,
  667. queueId: result.queueId
  668. }
  669. });
  670. return sendJson(res, 202, verificationEmailResponse(result));
  671. }
  672. const passwordResetMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/password-reset$/);
  673. if (passwordResetMatch && method === 'POST') {
  674. const target = getUser(Number(passwordResetMatch[1]));
  675. if (!target) return sendJson(res, 404, { error: '用户不存在。' });
  676. const result = await createAndSendPasswordResetEmail(target);
  677. logAudit({
  678. actorUserId: user.id,
  679. action: 'admin.password_reset',
  680. targetType: 'user',
  681. targetId: String(target.id),
  682. targetUserId: target.id,
  683. summary: {
  684. username: target.username,
  685. email: target.email,
  686. ok: result.ok,
  687. message: result.message,
  688. queueId: result.queueId
  689. }
  690. });
  691. return sendJson(res, result.ok ? 202 : 502, { result });
  692. }
  693. const temporaryPasswordMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/temporary-password$/);
  694. if (temporaryPasswordMatch && method === 'POST') {
  695. const target = getUser(Number(temporaryPasswordMatch[1]));
  696. if (!target) return sendJson(res, 404, { error: '用户不存在。' });
  697. const body = await readJson(req);
  698. let updated;
  699. try {
  700. updated = updateUser(target.id, { password: body.password });
  701. } catch (error) {
  702. if (error?.message === '密码至少需要 8 位。') return sendJson(res, 400, { error: error.message });
  703. throw error;
  704. }
  705. logAudit({
  706. actorUserId: user.id,
  707. action: 'admin.temporary_password',
  708. targetType: 'user',
  709. targetId: String(target.id),
  710. targetUserId: target.id,
  711. summary: {
  712. username: target.username,
  713. email: target.email,
  714. passwordSet: true
  715. }
  716. });
  717. return sendJson(res, 200, { user: updated });
  718. }
  719. const userMatch = pathname.match(/^\/api\/admin\/users\/(\d+)$/);
  720. if (userMatch && method === 'PATCH') {
  721. const body = await readJson(req);
  722. let updated;
  723. try {
  724. updated = updateUser(Number(userMatch[1]), {
  725. role: body.role,
  726. status: body.status,
  727. password: body.password
  728. });
  729. } catch (error) {
  730. if (['用户状态不正确。', '密码至少需要 8 位。'].includes(error?.message)) {
  731. return sendJson(res, 400, { error: error.message });
  732. }
  733. throw error;
  734. }
  735. return sendJson(res, updated ? 200 : 404, { user: updated });
  736. }
  737. return sendJson(res, 404, { error: 'Not found.' });
  738. }
  739. function adminAuditFilters(searchParams) {
  740. const requested = {
  741. actorUserId: auditUserIdParam(searchParams.get('actorUserId'), { allowSystem: true }),
  742. targetUserId: auditUserIdParam(searchParams.get('targetUserId')),
  743. action: auditTextParam(searchParams.get('action')),
  744. from: auditDateParam(searchParams.get('from')),
  745. to: auditDateParam(searchParams.get('to'))
  746. };
  747. return Object.fromEntries(
  748. ['actorUserId', 'targetUserId', 'action', 'from', 'to']
  749. .filter((key) => requested[key] !== undefined)
  750. .map((key) => [key, requested[key]])
  751. );
  752. }
  753. function sendAdminTransferError(res, error) {
  754. if (['域名不存在。', 'DNS 凭据不存在。', 'API Token 不存在。'].includes(error?.message)) {
  755. return sendJson(res, 404, { error: error.message });
  756. }
  757. if (['目标用户不可用。', 'DNS 凭据归属不一致。'].includes(error?.message)) {
  758. return sendJson(res, 400, { error: error.message });
  759. }
  760. throw error;
  761. }
  762. function sendAdminMigrationError(res, error) {
  763. if (['源用户不存在。'].includes(error?.message)) return sendJson(res, 404, { error: error.message });
  764. if ([
  765. '目标用户不可用。',
  766. '源用户和目标用户不能相同。',
  767. '确认文本不匹配。'
  768. ].includes(error?.message)) {
  769. return sendJson(res, 400, { error: error.message });
  770. }
  771. throw error;
  772. }
  773. function auditUserIdParam(value, { allowSystem = false } = {}) {
  774. if (value === null) return undefined;
  775. const text = String(value).trim();
  776. if (!text) return undefined;
  777. if (allowSystem && ['system', 'null'].includes(text.toLowerCase())) return null;
  778. return /^[1-9]\d*$/.test(text) ? Number(text) : undefined;
  779. }
  780. function auditTextParam(value) {
  781. const text = String(value || '').trim();
  782. return text || undefined;
  783. }
  784. function auditDateParam(value) {
  785. const text = String(value || '').trim();
  786. if (!text) return undefined;
  787. const dateOnly = text.match(/^(\d{4})-(\d{2})-(\d{2})$/);
  788. if (dateOnly) {
  789. const year = Number(dateOnly[1]);
  790. const month = Number(dateOnly[2]);
  791. const day = Number(dateOnly[3]);
  792. const date = new Date(Date.UTC(year, month - 1, day));
  793. if (
  794. date.getUTCFullYear() === year &&
  795. date.getUTCMonth() === month - 1 &&
  796. date.getUTCDate() === day
  797. ) {
  798. return date.toISOString();
  799. }
  800. return undefined;
  801. }
  802. if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(text)) return undefined;
  803. const date = new Date(text);
  804. return !Number.isNaN(date.getTime()) && date.toISOString() === text ? text : undefined;
  805. }
  806. async function sendMailFromBody(body, user) {
  807. const settings = runtimeSettings();
  808. const from = extractAddress(body.from);
  809. if (!from) throw new Error('发件人地址格式不正确。');
  810. const recipients = parseAddressList(body.to);
  811. if (!recipients.length) throw new Error('收件人地址格式不正确。');
  812. const fromDomain = domainFromAddress(from);
  813. const domain = getDomainByName(fromDomain, { userId: user.id, includePrivate: true });
  814. if (!domain) throw new Error(`发件域名 ${fromDomain} 不属于当前用户或尚未添加。`);
  815. if (settings.sendRequiresVerified && !domain.status?.verified) {
  816. throw new Error(`发件域名 ${fromDomain} 尚未完成验证。`);
  817. }
  818. const rawMessage = buildMessage({
  819. from,
  820. to: recipients,
  821. subject: body.subject || '(no subject)',
  822. text: body.text || '',
  823. html: body.html || '',
  824. baseUrl: settings.appBaseUrl
  825. });
  826. const signed = signMessageForDomain(rawMessage, domain);
  827. const smtpTransport = smtpTransportForSend(body, domain, user);
  828. try {
  829. const smtpResult = await sendViaSmtp({
  830. host: smtpTransport.host,
  831. port: smtpTransport.port,
  832. secure: smtpTransport.secure,
  833. username: smtpTransport.username,
  834. password: smtpTransport.password,
  835. helo: smtpTransport.helo,
  836. mailFrom: from,
  837. recipients,
  838. rawMessage: signed
  839. });
  840. logSendEvent({
  841. userId: user.id,
  842. domainId: domain.id,
  843. smtpRelayId: smtpTransport.smtpRelayId,
  844. sender: from,
  845. recipients,
  846. subject: body.subject || '(no subject)',
  847. status: 'queued',
  848. detail: smtpResult.message,
  849. queueId: smtpResult.queueId,
  850. deliveryLog: smtpResult.deliveryLog
  851. });
  852. return {
  853. queued: true,
  854. domain: domain.domain,
  855. recipients,
  856. smtp: smtpResult.message,
  857. queueId: smtpResult.queueId,
  858. smtpRelayId: smtpTransport.smtpRelayId
  859. };
  860. } catch (error) {
  861. logSendEvent({
  862. userId: user.id,
  863. domainId: domain.id,
  864. smtpRelayId: smtpTransport.smtpRelayId,
  865. sender: from,
  866. recipients,
  867. subject: body.subject || '(no subject)',
  868. status: 'failed',
  869. detail: error.message,
  870. deliveryLog: deliveryLogFromError(error)
  871. });
  872. throw error;
  873. }
  874. }
  875. function deliveryLogFromError(error) {
  876. if (Array.isArray(error?.deliveryLog)) return error.deliveryLog;
  877. return [{
  878. at: new Date().toISOString(),
  879. phase: 'error',
  880. direction: 'system',
  881. message: error?.message || 'Unknown SMTP delivery error',
  882. ok: false
  883. }];
  884. }
  885. function smtpTransportForSend(body, domain, user) {
  886. const requestedRelayId = Number(body.smtpRelayId || 0) || null;
  887. if (requestedRelayId) {
  888. const relay = getSmtpRelay(requestedRelayId, user.id, { includePassword: true });
  889. if (!relay) throw new Error('SMTP 出口不存在。');
  890. return smtpTransportFromRelay(relay);
  891. }
  892. if (domain.smtpRelayId) {
  893. const relay = getSmtpRelay(domain.smtpRelayId, user.id, { includePassword: true });
  894. if (!relay) throw new Error('SMTP 出口不存在。');
  895. return smtpTransportFromRelay(relay);
  896. }
  897. const defaultRelay = getDefaultSmtpRelay(user.id, { includePassword: true });
  898. if (defaultRelay) return smtpTransportFromRelay(defaultRelay);
  899. return {
  900. smtpRelayId: null,
  901. host: envConfig.smtpHost,
  902. port: envConfig.smtpPort,
  903. secure: envConfig.smtpSecure,
  904. username: envConfig.smtpUser,
  905. password: envConfig.smtpPassword,
  906. helo: envConfig.smtpHelo
  907. };
  908. }
  909. function smtpTransportFromRelay(relay) {
  910. return {
  911. smtpRelayId: relay.id,
  912. host: relay.host,
  913. port: relay.port,
  914. secure: relay.secure,
  915. username: relay.username,
  916. password: relay.password || '',
  917. helo: relay.helo || envConfig.smtpHelo
  918. };
  919. }
  920. function runBackground(promise) {
  921. promise.catch((error) => console.error(error));
  922. }
  923. async function handleRegister(req, res) {
  924. const body = await readJson(req);
  925. try {
  926. const { user, accountToken } = createUserWithAccountToken({
  927. username: body.username,
  928. email: body.email,
  929. password: body.password,
  930. status: 'pending_email'
  931. }, emailVerificationPurpose, { ttlMinutes: 24 * 60 });
  932. const emailResult = await sendVerificationEmail(user, accountToken.token);
  933. return sendRegisterSuccess(req, res, user, emailResult);
  934. } catch (error) {
  935. if (isUniqueError(error)) return sendAuthError(req, res, 409, '用户名或邮箱已被注册。', '/register');
  936. return sendAuthError(req, res, 400, error.message || '注册失败。', '/register');
  937. }
  938. }
  939. async function handleResendVerification(req, res) {
  940. const body = await readJson(req);
  941. const user = getUserByLogin(body.email);
  942. if (user?.status === 'pending_email') {
  943. runBackground(createAndSendVerificationEmail(user));
  944. }
  945. return sendJson(res, 202, publicVerificationResendResponse());
  946. }
  947. async function handleForgotPassword(req, res) {
  948. const body = await readJson(req);
  949. const user = getUserByLogin(body.email);
  950. if (user) runBackground(createAndSendPasswordResetEmail(user));
  951. return sendJson(res, 202, publicForgotPasswordResponse());
  952. }
  953. async function handleResetPassword(req, res) {
  954. const body = await readJson(req);
  955. if (String(body.password || '').length < 8) return sendJson(res, 400, { error: '密码至少需要 8 位。' });
  956. const consumed = consumeAccountToken(body.token, passwordResetPurpose);
  957. if (!consumed) return sendJson(res, 400, { error: '重置链接无效或已过期。' });
  958. updateUser(consumed.userId, { password: body.password });
  959. return sendJson(res, 200, { message: '密码已重置,请使用新密码登录。' });
  960. }
  961. async function handleVerifyEmail(req, res, url) {
  962. if ((req.method || 'GET') !== 'GET') return sendJson(res, 404, { error: 'Not found.' });
  963. const token = String(url.searchParams.get('token') || '').trim();
  964. if (!token) return sendJson(res, 400, { error: '验证链接无效或已过期。' });
  965. const consumed = consumeAccountToken(token, emailVerificationPurpose);
  966. if (!consumed) return sendJson(res, 400, { error: '验证链接无效或已过期。' });
  967. const user = markUserEmailVerified(consumed.userId);
  968. if (!user) return sendJson(res, 400, { error: '验证链接无效或已过期。' });
  969. return sendJson(res, 200, {
  970. user,
  971. message: '邮箱验证成功,请等待管理员审核。'
  972. });
  973. }
  974. async function createAndSendVerificationEmail(user) {
  975. const settings = systemMailSettingsForSend();
  976. if (!systemMailConfigured(settings)) return { ok: false, message: '系统邮件未配置。' };
  977. invalidateAccountTokens(user.id, emailVerificationPurpose);
  978. const accountToken = createAccountToken(user.id, emailVerificationPurpose, { ttlMinutes: 24 * 60 });
  979. const result = await sendVerificationEmailWithSettings(user, accountToken.token, settings);
  980. if (!result.ok) invalidateAccountTokens(user.id, emailVerificationPurpose);
  981. return result;
  982. }
  983. async function createAndSendPasswordResetEmail(user) {
  984. const settings = systemMailSettingsForSend();
  985. if (!systemMailConfigured(settings)) return { ok: false, message: '系统邮件未配置。' };
  986. invalidateAccountTokens(user.id, passwordResetPurpose);
  987. const accountToken = createAccountToken(user.id, passwordResetPurpose, { ttlMinutes: 60 });
  988. const result = await sendSystemEmail(settings, buildPasswordResetEmail({
  989. appBaseUrl: settings.appBaseUrl,
  990. to: user.email,
  991. token: accountToken.token,
  992. fromEmail: settings.fromEmail,
  993. fromName: settings.fromName
  994. }));
  995. if (!result.ok) invalidateAccountTokens(user.id, passwordResetPurpose);
  996. return result;
  997. }
  998. async function sendVerificationEmail(user, token) {
  999. const settings = systemMailSettingsForSend();
  1000. if (!systemMailConfigured(settings)) {
  1001. return { ok: false, message: '系统邮件未配置。' };
  1002. }
  1003. return await sendVerificationEmailWithSettings(user, token, settings);
  1004. }
  1005. async function sendVerificationEmailWithSettings(user, token, settings) {
  1006. return await sendSystemEmail(settings, buildVerificationEmail({
  1007. appBaseUrl: settings.appBaseUrl,
  1008. to: user.email,
  1009. token,
  1010. fromEmail: settings.fromEmail,
  1011. fromName: settings.fromName
  1012. }));
  1013. }
  1014. function systemMailConfigured(settings) {
  1015. return Boolean(settings.host && extractAddress(settings.fromEmail));
  1016. }
  1017. function systemMailSettingsForSend() {
  1018. return {
  1019. ...getSystemEmailSettings({ includeSecret: true }),
  1020. appBaseUrl: runtimeSettings().appBaseUrl
  1021. };
  1022. }
  1023. function publicVerificationResendResponse() {
  1024. return {
  1025. message: '如果账号需要验证,我们会发送验证邮件。'
  1026. };
  1027. }
  1028. function publicForgotPasswordResponse() {
  1029. return {
  1030. message: '如果邮箱存在,我们会发送密码重置邮件。'
  1031. };
  1032. }
  1033. function verificationEmailResponse(result) {
  1034. return {
  1035. verificationEmailSent: Boolean(result.ok),
  1036. message: result.ok ? '验证邮件已发送。' : '验证邮件暂未发送,请稍后重试或联系管理员。',
  1037. result: {
  1038. ok: Boolean(result.ok),
  1039. message: result.message || '',
  1040. queueId: result.queueId || ''
  1041. }
  1042. };
  1043. }
  1044. async function handleLogin(req, res) {
  1045. const body = await readJson(req);
  1046. const user = verifyUserCredentials(body.username || body.email, body.password);
  1047. if (!user) return sendAuthError(req, res, 401, '账号或密码不正确。', '/login');
  1048. if (user.status !== 'active') return sendAuthError(req, res, 403, loginStatusMessage(user.status), '/login');
  1049. return sendAuthSuccess(req, res, 200, user);
  1050. }
  1051. function sendRegisterSuccess(req, res, user, emailResult = { ok: false }) {
  1052. const message = emailResult.ok
  1053. ? '注册成功,验证邮件已发送,请先验证邮箱,验证后等待管理员审核。'
  1054. : '注册成功,请先验证邮箱;验证邮件暂未发送,请联系管理员或稍后重试。';
  1055. if (wantsHtmlRedirect(req)) return redirect(res, `/login?error=${encodeURIComponent(message)}`, 303);
  1056. return sendJson(res, 201, {
  1057. user,
  1058. message,
  1059. verificationEmailSent: Boolean(emailResult.ok)
  1060. });
  1061. }
  1062. function sendAuthSuccess(req, res, status, user) {
  1063. const token = createSessionToken(user);
  1064. const cookie = sessionCookie(token);
  1065. if (wantsHtmlRedirect(req)) return redirect(res, '/', 303, { 'Set-Cookie': cookie });
  1066. res.writeHead(status, {
  1067. 'Content-Type': 'application/json; charset=utf-8',
  1068. 'Set-Cookie': cookie
  1069. });
  1070. res.end(JSON.stringify({ user }));
  1071. }
  1072. function sendAuthError(req, res, status, message, fallbackPath) {
  1073. if (wantsHtmlRedirect(req)) return redirect(res, `${fallbackPath}?error=${encodeURIComponent(message)}`, 303);
  1074. return sendJson(res, status, { error: message });
  1075. }
  1076. function loginStatusMessage(status) {
  1077. if (status === 'pending_email') return '请先验证邮箱。';
  1078. if (status === 'pending_review') return '账号正在等待管理员审核。';
  1079. if (status === 'disabled') return '账号已被禁用。';
  1080. return '账号或密码不正确。';
  1081. }
  1082. function handleLogout(res) {
  1083. res.writeHead(200, {
  1084. 'Content-Type': 'application/json; charset=utf-8',
  1085. 'Set-Cookie': 'mailhub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'
  1086. });
  1087. res.end(JSON.stringify({ ok: true }));
  1088. }
  1089. function getRequestUser(req, pathname) {
  1090. const sessionUser = getSessionUser(req);
  1091. if (sessionUser) return sessionUser;
  1092. const auth = req.headers.authorization || '';
  1093. if (auth.startsWith('Basic ')) {
  1094. const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
  1095. const index = decoded.indexOf(':');
  1096. const user = authenticateUser(decoded.slice(0, index), decoded.slice(index + 1));
  1097. if (user) return user;
  1098. }
  1099. if (pathname === '/api/send' && auth.startsWith('Bearer ')) {
  1100. const token = auth.slice(7);
  1101. const user = verifyApiToken(token);
  1102. if (user) return user;
  1103. if (envConfig.legacyApiToken && safeEqual(token, envConfig.legacyApiToken)) return getAdminUser();
  1104. }
  1105. return null;
  1106. }
  1107. function getSessionUser(req) {
  1108. const token = parseCookies(req.headers.cookie || '').mailhub_session;
  1109. if (!token || !token.includes('.')) return null;
  1110. const [payload, signature] = token.split('.');
  1111. if (!payload || !signature || !safeEqual(signature, signSessionPayload(payload))) return null;
  1112. try {
  1113. const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
  1114. if (Number(data.exp) <= Date.now()) return null;
  1115. const user = getUser(Number(data.uid));
  1116. return user?.status === 'active' ? user : null;
  1117. } catch {
  1118. return null;
  1119. }
  1120. }
  1121. function createSessionToken(user) {
  1122. const payload = Buffer.from(JSON.stringify({
  1123. uid: user.id,
  1124. exp: Date.now() + 12 * 60 * 60 * 1000,
  1125. nonce: crypto.randomBytes(10).toString('hex')
  1126. })).toString('base64url');
  1127. return `${payload}.${signSessionPayload(payload)}`;
  1128. }
  1129. function signSessionPayload(payload) {
  1130. return crypto.createHmac('sha256', envConfig.sessionSecret).update(payload).digest('base64url');
  1131. }
  1132. function sessionCookie(token) {
  1133. return [`mailhub_session=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=43200'].join('; ');
  1134. }
  1135. function publicConfig(user) {
  1136. const settings = runtimeSettings();
  1137. const smtpCredential = getSmtpCredential(user.id);
  1138. return {
  1139. ...settings,
  1140. smtpHost: envConfig.smtpHost ? 'configured' : '',
  1141. submission: {
  1142. enabled: envConfig.submissionEnabled,
  1143. host: envConfig.submissionHost,
  1144. ports: publicSubmissionListeners(envConfig.submissionListeners),
  1145. username: smtpCredential?.username || '',
  1146. passwordSet: Boolean(smtpCredential?.passwordSet),
  1147. tls: Boolean(envConfig.submissionTlsCert && envConfig.submissionTlsKey),
  1148. requireTlsForAuth: !envConfig.submissionAllowInsecureAuth
  1149. },
  1150. apiTokenSet: Boolean(envConfig.legacyApiToken),
  1151. usingDefaultAdminPassword: user.role === 'admin' && envConfig.adminPassword === 'change-this-admin-password'
  1152. };
  1153. }
  1154. function runtimeSettings() {
  1155. const settings = getSettings(defaultSettings);
  1156. return {
  1157. appBaseUrl: settings.appBaseUrl,
  1158. mailHostname: settings.mailHostname,
  1159. sendingIp: settings.sendingIp,
  1160. defaultSpfMechanisms: settings.defaultSpfMechanisms,
  1161. dmarcPolicy: normalizeDmarcPolicy(settings.dmarcPolicy),
  1162. dmarcRua: settings.dmarcRua,
  1163. sendRequiresVerified: String(settings.sendRequiresVerified).toLowerCase() === 'true'
  1164. };
  1165. }
  1166. async function adminRuntimeSettings() {
  1167. const settings = runtimeSettings();
  1168. return {
  1169. ...settings,
  1170. systemChecks: await buildSystemDnsChecks(settings)
  1171. };
  1172. }
  1173. async function serveStatic(req, res, url) {
  1174. const publicDir = path.join(__dirname, '..', 'public');
  1175. const pathname = decodeURIComponent(resolveStaticPathname(url.pathname));
  1176. const filePath = path.normalize(path.join(publicDir, pathname));
  1177. if (!filePath.startsWith(publicDir) || !existsSync(filePath) || statSync(filePath).isDirectory()) {
  1178. return sendStaticFile(res, path.join(publicDir, 'index.html'));
  1179. }
  1180. return sendStaticFile(res, filePath);
  1181. }
  1182. async function sendStaticFile(res, filePath) {
  1183. const ext = path.extname(filePath);
  1184. const contentType = {
  1185. '.html': 'text/html; charset=utf-8',
  1186. '.css': 'text/css; charset=utf-8',
  1187. '.js': 'application/javascript; charset=utf-8',
  1188. '.json': 'application/json; charset=utf-8',
  1189. '.svg': 'image/svg+xml'
  1190. }[ext] || 'application/octet-stream';
  1191. res.writeHead(200, { 'Content-Type': contentType });
  1192. res.end(await readFile(filePath));
  1193. }
  1194. async function readJson(req) {
  1195. const chunks = [];
  1196. for await (const chunk of req) chunks.push(chunk);
  1197. if (!chunks.length) return {};
  1198. const raw = Buffer.concat(chunks).toString('utf8');
  1199. const contentType = String(req.headers['content-type'] || '').toLowerCase();
  1200. if (contentType.includes('application/x-www-form-urlencoded')) {
  1201. return Object.fromEntries(new URLSearchParams(raw).entries());
  1202. }
  1203. return JSON.parse(raw);
  1204. }
  1205. function sendJson(res, status, payload) {
  1206. res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
  1207. res.end(JSON.stringify(payload));
  1208. }
  1209. function redirect(res, location, status = 302, headers = {}) {
  1210. res.writeHead(status, { ...headers, Location: location });
  1211. res.end();
  1212. }
  1213. function wantsHtmlRedirect(req) {
  1214. const contentType = String(req.headers['content-type'] || '').toLowerCase();
  1215. const accept = String(req.headers.accept || '').toLowerCase();
  1216. return contentType.includes('application/x-www-form-urlencoded') && accept.includes('text/html');
  1217. }
  1218. function setSecurityHeaders(res) {
  1219. res.setHeader('X-Content-Type-Options', 'nosniff');
  1220. res.setHeader('X-Frame-Options', 'DENY');
  1221. res.setHeader('Referrer-Policy', 'same-origin');
  1222. }
  1223. function handleOptions(res) {
  1224. res.writeHead(204, {
  1225. 'Access-Control-Allow-Origin': '*',
  1226. 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
  1227. 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
  1228. });
  1229. res.end();
  1230. }
  1231. function parseCookies(header) {
  1232. const cookies = {};
  1233. for (const part of String(header || '').split(';')) {
  1234. const index = part.indexOf('=');
  1235. if (index === -1) continue;
  1236. cookies[part.slice(0, index).trim()] = part.slice(index + 1).trim();
  1237. }
  1238. return cookies;
  1239. }
  1240. function safeEqual(actual, expected) {
  1241. const a = Buffer.from(String(actual || ''));
  1242. const b = Buffer.from(String(expected || ''));
  1243. if (a.length !== b.length) return false;
  1244. return crypto.timingSafeEqual(a, b);
  1245. }
  1246. function normalizeDomain(input) {
  1247. const raw = String(input || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/\.$/, '');
  1248. const ascii = domainToASCII(raw);
  1249. if (!ascii || ascii.length > 253) return '';
  1250. if (!/^(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/.test(ascii)) return '';
  1251. return ascii;
  1252. }
  1253. function normalizeHostname(input) {
  1254. return normalizeDomain(input) || String(input || '').trim().toLowerCase();
  1255. }
  1256. function normalizeSelector(input) {
  1257. const value = String(input || '').trim().toLowerCase();
  1258. return /^[a-z0-9][a-z0-9-]{0,62}$/.test(value) ? value : '';
  1259. }
  1260. function normalizeDmarcPolicy(input) {
  1261. const value = String(input || '').trim().toLowerCase();
  1262. return ['none', 'quarantine', 'reject'].includes(value) ? value : 'none';
  1263. }
  1264. function smtpRelayPatch(body) {
  1265. const patch = {};
  1266. for (const key of ['name', 'host', 'port', 'secure', 'username', 'password', 'helo', 'isDefault']) {
  1267. if (Object.hasOwn(body, key)) patch[key] = body[key];
  1268. }
  1269. return patch;
  1270. }
  1271. function boolString(value) {
  1272. return String(value).toLowerCase() === 'true' || value === true ? 'true' : 'false';
  1273. }
  1274. function defaultSelector() {
  1275. const d = new Date();
  1276. return `mh${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
  1277. }
  1278. async function buildDnsGuideAfterApply(domain, applyResult) {
  1279. const appliedKeys = new Set((applyResult.results || [])
  1280. .filter((result) => result.ok && !result.skipped)
  1281. .map((result) => result.key));
  1282. let guide = await buildDnsGuide(domain);
  1283. for (let attempt = 0; attempt < 2 && hasUnpropagatedAppliedRecords(guide, appliedKeys); attempt += 1) {
  1284. await sleep(1800);
  1285. guide = await buildDnsGuide(domain);
  1286. }
  1287. return markUnpropagatedAppliedRecords(guide, appliedKeys);
  1288. }
  1289. function hasUnpropagatedAppliedRecords(guide, appliedKeys) {
  1290. return (guide.records || []).some((record) => appliedKeys.has(record.key) && record.status !== 'ok');
  1291. }
  1292. function markUnpropagatedAppliedRecords(guide, appliedKeys) {
  1293. const records = (guide.records || []).map((record) => {
  1294. if (!appliedKeys.has(record.key) || record.status === 'ok') return record;
  1295. return {
  1296. ...record,
  1297. status: 'pending',
  1298. warnings: [
  1299. ...(record.warnings || []),
  1300. '已提交到 DNS 服务商,正在等待公共 DNS 传播;稍后点击“立即检查”刷新。'
  1301. ]
  1302. };
  1303. });
  1304. return {
  1305. ...guide,
  1306. records,
  1307. warnings: collectGuideWarnings(records)
  1308. };
  1309. }
  1310. function collectGuideWarnings(records) {
  1311. return records.flatMap((record) => record.warnings || []);
  1312. }
  1313. function sleep(ms) {
  1314. return new Promise((resolve) => setTimeout(resolve, ms));
  1315. }
  1316. function isUniqueError(error) {
  1317. return /UNIQUE constraint failed/i.test(String(error?.message || ''));
  1318. }
  1319. function isLoginAsset(pathname) {
  1320. return pathname.startsWith('/assets/')
  1321. || [
  1322. '/login',
  1323. '/register',
  1324. '/forgot-password',
  1325. '/resend-verification',
  1326. '/reset-password',
  1327. '/login.html',
  1328. '/login.css',
  1329. '/login.js'
  1330. ].includes(pathname);
  1331. }
  1332. function resolveStaticPathname(pathname) {
  1333. if (pathname === '/') return '/index.html';
  1334. if (['/login', '/register', '/forgot-password', '/resend-verification', '/reset-password'].includes(pathname)) return '/login.html';
  1335. return pathname;
  1336. }
  1337. function loadDotEnv() {
  1338. const file = path.join(process.cwd(), '.env');
  1339. if (!existsSync(file)) return;
  1340. const lines = readFileSync(file, 'utf8').split(/\r?\n/);
  1341. for (const line of lines) {
  1342. const trimmed = line.trim();
  1343. if (!trimmed || trimmed.startsWith('#')) continue;
  1344. const index = trimmed.indexOf('=');
  1345. if (index === -1) continue;
  1346. const key = trimmed.slice(0, index).trim();
  1347. let value = trimmed.slice(index + 1).trim();
  1348. if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
  1349. if (!(key in process.env)) process.env[key] = value;
  1350. }
  1351. }