server.js 72 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945
  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. createInboundMailbox,
  15. createSendEvent,
  16. createTrackingLink,
  17. createUserWithAccountToken,
  18. createWebhook,
  19. consumeAccountToken,
  20. deleteApiToken,
  21. deleteDnsCredential,
  22. deleteDomain,
  23. deleteSmtpCredential,
  24. deleteWebhook,
  25. enqueueWebhookTestDelivery,
  26. getAdminResourceInventory,
  27. getAdminUser,
  28. getDnsCredential,
  29. getDomain,
  30. getDomainByName,
  31. getInboundMessage,
  32. getSendEvent,
  33. getSendAnalytics,
  34. getSettings,
  35. getDefaultSmtpRelay,
  36. getSmtpRelay,
  37. getSmtpCredential,
  38. getSystemEmailSettings,
  39. getUser,
  40. getUserByLogin,
  41. initDatabase,
  42. invalidateAccountTokens,
  43. listApiTokens,
  44. listAuditLogs,
  45. listDnsCredentials,
  46. listDomains,
  47. listInboundMailboxes,
  48. listInboundMessages,
  49. listSendEvents,
  50. listSmtpCredentials,
  51. listSmtpRelays,
  52. listUsersWithResourceCounts,
  53. listWebhookDeliveries,
  54. listWebhooks,
  55. logAudit,
  56. logSendEvent,
  57. markInboundMessageRead,
  58. markUserEmailVerified,
  59. previewUserMerge,
  60. replayWebhookDelivery,
  61. rotateWebhookSecret,
  62. saveDnsCredential,
  63. saveDomainStatus,
  64. saveSettings,
  65. saveSmtpRelay,
  66. saveSmtpCredential,
  67. saveSystemEmailSettings,
  68. seedAdminUser,
  69. seedSmtpCredential,
  70. deleteSmtpRelay,
  71. transferApiTokens,
  72. transferDnsCredential,
  73. transferDomain,
  74. updateDkim,
  75. updateDomain,
  76. updateUser,
  77. updateWebhook,
  78. executeUserMerge,
  79. finalizeSendEvent,
  80. findSendEventByTrackingToken,
  81. findTrackingLinkByToken,
  82. recordTrackingEvent,
  83. verifyApiToken,
  84. verifyUserCredentials
  85. } from './db.js';
  86. import { applyDnsSetup, testDnsCredential } from './dns-providers.js';
  87. import { startDnsAutoChecker } from './dns-auto-checker.js';
  88. import { startPostfixDeliveryTracker } from './delivery-tracker.js';
  89. import { assertSafeWebhookUrl, startWebhookWorker } from './webhook-dispatcher.js';
  90. import { buildDnsGuide, buildSystemDnsChecks } from './dns-guide.js';
  91. import { createDkimKeyPair } from './dkim.js';
  92. import {
  93. buildDeliverabilityHeaders,
  94. buildMessage,
  95. createFeedbackId,
  96. domainFromAddress,
  97. extractAddress,
  98. parseAddressList,
  99. resolveEnvelopeSender,
  100. sendViaSmtp,
  101. signMessageForDomain
  102. } from './mailer.js';
  103. import {
  104. parseSubmissionListeners,
  105. publicSubmissionListeners,
  106. startSubmissionServer
  107. } from './submission.js';
  108. import {
  109. buildPasswordResetEmail,
  110. buildVerificationEmail,
  111. sendSystemEmail
  112. } from './system-mail.js';
  113. import {
  114. classifyTrackingSource,
  115. createTrackingToken,
  116. decryptTrackingTarget,
  117. encryptTrackingTarget,
  118. hashTrackingClientIp,
  119. instrumentHtml,
  120. trackingTargetFingerprint,
  121. trackingReplayKey
  122. } from './tracking.js';
  123. import { startTrackingRetentionWorker } from './tracking-retention.js';
  124. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  125. loadDotEnv();
  126. const fallbackSecret = crypto
  127. .createHash('sha256')
  128. .update(`${process.env.ADMIN_PASSWORD || 'change-this-admin-password'}:${process.env.API_TOKEN || ''}`)
  129. .digest('hex');
  130. const envConfig = {
  131. port: Number(process.env.PORT || 3000),
  132. dataDir: process.env.DATA_DIR || path.join(process.cwd(), 'data'),
  133. adminUser: process.env.ADMIN_USER || 'admin',
  134. adminEmail: process.env.ADMIN_EMAIL || `${process.env.ADMIN_USER || 'admin'}@mailhub.local`,
  135. adminPassword: process.env.ADMIN_PASSWORD || 'change-this-admin-password',
  136. legacyApiToken: process.env.API_TOKEN || '',
  137. smtpHost: process.env.SMTP_HOST || '',
  138. smtpPort: Number(process.env.SMTP_PORT || 25),
  139. smtpSecure: String(process.env.SMTP_SECURE || '').toLowerCase() === 'true',
  140. smtpUser: process.env.SMTP_USERNAME || '',
  141. smtpPassword: process.env.SMTP_PASSWORD || '',
  142. smtpHelo: process.env.SMTP_HELO || process.env.MAIL_HOSTNAME || 'mailhub.local',
  143. postfixLogFile: process.env.POSTFIX_LOG_FILE || path.join(process.env.DATA_DIR || path.join(process.cwd(), 'data'), 'postfix-logs', 'mail.log'),
  144. postfixLogPollIntervalMs: Number(process.env.POSTFIX_LOG_POLL_INTERVAL_MS || 5000),
  145. deliveryTrackingEnabled: String(process.env.DELIVERY_TRACKING_ENABLED || 'true').toLowerCase() !== 'false',
  146. dnsAutoCheckEnabled: String(process.env.DNS_AUTO_CHECK_ENABLED || 'true').toLowerCase() !== 'false',
  147. dnsAutoCheckIntervalMs: Number(process.env.DNS_AUTO_CHECK_INTERVAL_MS || 60000),
  148. dnsAutoCheckLimit: Number(process.env.DNS_AUTO_CHECK_LIMIT || 25),
  149. webhookWorkerEnabled:
  150. String(process.env.WEBHOOK_WORKER_ENABLED || 'true').toLowerCase() !== 'false' &&
  151. String(process.env.WEBHOOK_WORKER_ENABLED || '') !== '0',
  152. webhookWorkerIntervalMs: Number(process.env.WEBHOOK_WORKER_INTERVAL_MS || 10000),
  153. webhookWorkerBatchSize: Number(process.env.WEBHOOK_WORKER_BATCH_SIZE || 3),
  154. submissionEnabled: String(process.env.SUBMISSION_ENABLED || 'true').toLowerCase() !== 'false',
  155. submissionHost: process.env.SUBMISSION_HOST || process.env.APP_BASE_URL?.replace(/^https?:\/\//, '') || 'localhost',
  156. submissionListeners: parseSubmissionListeners(process.env.SUBMISSION_PORTS),
  157. submissionUsername: process.env.SUBMISSION_USERNAME || '',
  158. submissionPassword: process.env.SUBMISSION_PASSWORD || '',
  159. submissionAllowInsecureAuth: String(process.env.SUBMISSION_ALLOW_INSECURE_AUTH || '').toLowerCase() === 'true',
  160. submissionTlsCert: process.env.SUBMISSION_TLS_CERT || '',
  161. submissionTlsKey: process.env.SUBMISSION_TLS_KEY || '',
  162. submissionMaxMessageBytes: Number(process.env.SUBMISSION_MAX_MESSAGE_BYTES || 50 * 1024 * 1024),
  163. inboundEnabled: String(process.env.INBOUND_ENABLED || 'true').toLowerCase() !== 'false',
  164. sessionSecret: process.env.SESSION_SECRET || fallbackSecret,
  165. trackingSecret: process.env.TRACKING_SECRET || process.env.SESSION_SECRET || fallbackSecret,
  166. trustProxy: String(process.env.TRUST_PROXY || '').toLowerCase() === 'true',
  167. trackingRetentionDays: Math.max(1, Number(process.env.TRACKING_RETENTION_DAYS || 180))
  168. };
  169. const defaultSettings = {
  170. appBaseUrl: process.env.APP_BASE_URL || 'http://127.0.0.1:3000',
  171. mailHostname: process.env.MAIL_HOSTNAME || 'mailhub.local',
  172. sendingIp: process.env.SENDING_IP || '',
  173. defaultSpfMechanisms: process.env.DEFAULT_SPF_MECHANISMS || 'include:spf.mailjet.com',
  174. dmarcPolicy: process.env.DMARC_POLICY || 'none',
  175. dmarcRua: process.env.DMARC_RUA || '',
  176. sendRequiresVerified: String(process.env.SEND_REQUIRES_VERIFIED || '').toLowerCase() === 'true' ? 'true' : 'false',
  177. engagementTrackingEnabled:
  178. String(process.env.ENGAGEMENT_TRACKING_ENABLED || '').toLowerCase() === 'true' ? 'true' : 'false',
  179. listUnsubscribeMailto: process.env.LIST_UNSUBSCRIBE_MAILTO || '',
  180. listUnsubscribeUrl: process.env.LIST_UNSUBSCRIBE_URL || '',
  181. listUnsubscribePostEnabled:
  182. String(process.env.LIST_UNSUBSCRIBE_POST_ENABLED || '').toLowerCase() === 'true' ? 'true' : 'false',
  183. feedbackIdEnabled: String(process.env.FEEDBACK_ID_ENABLED || 'true').toLowerCase() === 'false' ? 'false' : 'true',
  184. reportAbuseTo: process.env.REPORT_ABUSE_TO || '',
  185. csaComplaintsTo: process.env.CSA_COMPLAINTS_TO || '',
  186. bounceAddress: process.env.BOUNCE_ADDRESS || '',
  187. bounceEnvelopeEnabled:
  188. String(process.env.BOUNCE_ENVELOPE_ENABLED || '').toLowerCase() === 'true' ? 'true' : 'false'
  189. };
  190. const emailVerificationPurpose = 'email_verification';
  191. const passwordResetPurpose = 'password_reset';
  192. initDatabase(envConfig.dataDir, envConfig.sessionSecret);
  193. const admin = seedAdminUser({
  194. username: envConfig.adminUser,
  195. email: envConfig.adminEmail,
  196. password: envConfig.adminPassword
  197. });
  198. claimLegacyData(admin.id);
  199. seedSmtpCredential(admin.id, envConfig.submissionUsername, envConfig.submissionPassword);
  200. startPostfixDeliveryTracker({
  201. enabled: envConfig.deliveryTrackingEnabled,
  202. logFile: envConfig.postfixLogFile,
  203. pollIntervalMs: envConfig.postfixLogPollIntervalMs
  204. });
  205. startDnsAutoChecker({
  206. enabled: envConfig.dnsAutoCheckEnabled,
  207. intervalMs: envConfig.dnsAutoCheckIntervalMs,
  208. limit: envConfig.dnsAutoCheckLimit
  209. });
  210. startWebhookWorker({
  211. enabled: envConfig.webhookWorkerEnabled,
  212. intervalMs: envConfig.webhookWorkerIntervalMs,
  213. batchSize: envConfig.webhookWorkerBatchSize
  214. });
  215. startTrackingRetentionWorker({
  216. days: envConfig.trackingRetentionDays
  217. });
  218. const server = http.createServer(async (req, res) => {
  219. try {
  220. setSecurityHeaders(res);
  221. if (req.method === 'OPTIONS') return handleOptions(res);
  222. const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
  223. if (url.pathname === '/healthz') return sendJson(res, 200, { ok: true });
  224. if (url.pathname.startsWith('/t/') && await handleTrackingRequest(req, res, url)) return;
  225. if (req.method === 'POST' && (url.pathname === '/api/register' || url.pathname === '/register')) return await handleRegister(req, res);
  226. if (req.method === 'POST' && (url.pathname === '/api/login' || url.pathname === '/login')) return await handleLogin(req, res);
  227. if (req.method === 'POST' && url.pathname === '/api/logout') return handleLogout(res);
  228. if (url.pathname === '/api/auth/verify-email') return await handleVerifyEmail(req, res, url);
  229. if (req.method === 'POST' && url.pathname === '/api/auth/resend-verification') return await handleResendVerification(req, res);
  230. if (req.method === 'POST' && url.pathname === '/api/auth/forgot-password') return await handleForgotPassword(req, res);
  231. if (req.method === 'POST' && url.pathname === '/api/auth/reset-password') return await handleResetPassword(req, res);
  232. const user = getRequestUser(req, url.pathname);
  233. if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname === '/landing.html') {
  234. if (user && (url.pathname === '/' || url.pathname === '/index.html')) {
  235. return sendStaticFile(res, path.join(__dirname, '..', 'public', 'index.html'), { noStore: true });
  236. }
  237. if (!user && (url.pathname === '/' || url.pathname === '/landing.html')) {
  238. return sendStaticFile(res, path.join(__dirname, '..', 'public', 'landing.html'), { noStore: true });
  239. }
  240. if (user && url.pathname === '/landing.html') {
  241. return redirect(res, '/');
  242. }
  243. }
  244. if (isLoginAsset(url.pathname)) {
  245. if ((url.pathname === '/login' || url.pathname === '/register') && user) return redirect(res, '/');
  246. return await serveStatic(req, res, url);
  247. }
  248. if (!user) {
  249. if (url.pathname.startsWith('/api/')) return sendJson(res, 401, { error: 'Authentication required.' });
  250. return redirect(res, '/login');
  251. }
  252. if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url, user);
  253. return await serveStatic(req, res, url);
  254. } catch (error) {
  255. console.error(error);
  256. return sendJson(res, 500, { error: error.message || 'Internal server error.' });
  257. }
  258. });
  259. server.listen(envConfig.port, '0.0.0.0', () => {
  260. console.log(`MailHub listening on 0.0.0.0:${envConfig.port}`);
  261. });
  262. startSubmissionServer({
  263. enabled: envConfig.submissionEnabled,
  264. listeners: envConfig.submissionListeners,
  265. hostname: envConfig.submissionHost,
  266. allowInsecureAuth: envConfig.submissionAllowInsecureAuth,
  267. inboundEnabled: envConfig.inboundEnabled,
  268. maxMessageBytes: envConfig.submissionMaxMessageBytes,
  269. tlsCertPath: envConfig.submissionTlsCert,
  270. tlsKeyPath: envConfig.submissionTlsKey,
  271. relayHost: envConfig.smtpHost,
  272. relayPort: envConfig.smtpPort,
  273. relaySecure: envConfig.smtpSecure,
  274. relayUsername: envConfig.smtpUser,
  275. relayPassword: envConfig.smtpPassword,
  276. relayHelo: envConfig.smtpHelo,
  277. getTrackingSettings() {
  278. const settings = runtimeSettings();
  279. return {
  280. enabled: settings.engagementTrackingEnabled,
  281. appBaseUrl: settings.appBaseUrl,
  282. secret: envConfig.trackingSecret
  283. };
  284. },
  285. getDeliverabilitySettings() {
  286. return {
  287. ...runtimeSettings(),
  288. secret: envConfig.trackingSecret
  289. };
  290. }
  291. });
  292. async function handleApi(req, res, url, user) {
  293. const method = req.method || 'GET';
  294. const pathname = url.pathname;
  295. if (method === 'GET' && pathname === '/api/me') {
  296. return sendJson(res, 200, { user });
  297. }
  298. if (method === 'GET' && pathname === '/api/config') {
  299. return sendJson(res, 200, publicConfig(user));
  300. }
  301. if (method === 'GET' && pathname === '/api/domains') {
  302. return sendJson(res, 200, { domains: listDomains(user.id) });
  303. }
  304. if (method === 'POST' && pathname === '/api/domains') {
  305. const body = await readJson(req);
  306. const settings = runtimeSettings();
  307. const domain = normalizeDomain(body.domain);
  308. if (!domain) return sendJson(res, 400, { error: '域名格式不正确。' });
  309. const selector = normalizeSelector(body.selector || defaultSelector());
  310. if (!selector) return sendJson(res, 400, { error: 'DKIM selector 格式不正确。' });
  311. const dnsCredentialId = Number(body.dnsCredentialId || 0) || null;
  312. if (dnsCredentialId && !getDnsCredential(dnsCredentialId, user.id)) {
  313. return sendJson(res, 400, { error: 'DNS 凭据不存在。' });
  314. }
  315. const smtpRelayId = Number(body.smtpRelayId || 0) || null;
  316. if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
  317. return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
  318. }
  319. const keys = createDkimKeyPair();
  320. try {
  321. const row = createDomain(user.id, {
  322. domain,
  323. selector,
  324. dnsCredentialId,
  325. smtpRelayId,
  326. verificationToken: crypto.randomBytes(18).toString('hex'),
  327. dkimPublic: keys.publicKey,
  328. dkimPrivate: keys.privateKey,
  329. senderHost: normalizeHostname(body.senderHost || settings.mailHostname),
  330. sendingIp: String(body.sendingIp || settings.sendingIp).trim(),
  331. spfExtra: String(body.spfExtra ?? settings.defaultSpfMechanisms).trim(),
  332. dmarcPolicy: normalizeDmarcPolicy(body.dmarcPolicy || settings.dmarcPolicy),
  333. dmarcRua: String(body.dmarcRua ?? settings.dmarcRua).trim()
  334. });
  335. return sendJson(res, 201, { domain: row });
  336. } catch (error) {
  337. if (isUniqueError(error)) return sendJson(res, 409, { error: '该域名已被添加。' });
  338. throw error;
  339. }
  340. }
  341. if (method === 'GET' && pathname === '/api/events') {
  342. return sendJson(res, 200, { events: listSendEvents(user.id) });
  343. }
  344. if (method === 'GET' && pathname === '/api/inbound-mailboxes') {
  345. return sendJson(res, 200, { mailboxes: listInboundMailboxes(user.id) });
  346. }
  347. if (method === 'POST' && pathname === '/api/inbound-mailboxes') {
  348. const body = await readJson(req);
  349. try {
  350. return sendJson(res, 201, {
  351. mailbox: createInboundMailbox(user.id, {
  352. address: body.address,
  353. displayName: body.displayName
  354. })
  355. });
  356. } catch (error) {
  357. if (isUniqueError(error)) return sendJson(res, 409, { error: '该收信邮箱已存在。' });
  358. return sendJson(res, 400, { error: error.message || '收信邮箱创建失败。' });
  359. }
  360. }
  361. if (method === 'GET' && pathname === '/api/inbound-messages') {
  362. return sendJson(res, 200, {
  363. messages: listInboundMessages(user.id, {
  364. mailboxId: Number(url.searchParams.get('mailboxId') || 0) || null
  365. })
  366. });
  367. }
  368. const inboundMessageMatch = pathname.match(/^\/api\/inbound-messages\/(\d+)$/);
  369. if (inboundMessageMatch) {
  370. const id = Number(inboundMessageMatch[1]);
  371. if (method === 'GET') {
  372. const message = getInboundMessage(user.id, id);
  373. return sendJson(res, message ? 200 : 404, { message });
  374. }
  375. if (method === 'PATCH') {
  376. const body = await readJson(req);
  377. const message = markInboundMessageRead(user.id, id, body.read !== false);
  378. return sendJson(res, message ? 200 : 404, { message });
  379. }
  380. }
  381. const sendEventMatch = pathname.match(/^\/api\/events\/(\d+)$/);
  382. if (sendEventMatch && method === 'GET') {
  383. const event = getSendEvent(user.id, Number(sendEventMatch[1]), { trackingSecret: envConfig.trackingSecret });
  384. return sendJson(res, event ? 200 : 404, { event });
  385. }
  386. if (method === 'GET' && pathname === '/api/analytics') {
  387. return sendJson(res, 200, {
  388. analytics: getSendAnalytics(user.id, {
  389. days: Number(url.searchParams.get('days') || 7),
  390. trackingSecret: envConfig.trackingSecret
  391. })
  392. });
  393. }
  394. if (method === 'GET' && pathname === '/api/smtp-credential') {
  395. return sendJson(res, 200, { credential: getSmtpCredential(user.id, { includePassword: true }) });
  396. }
  397. if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && pathname === '/api/smtp-credential') {
  398. const body = await readJson(req);
  399. try {
  400. const current = getSmtpCredential(user.id);
  401. saveSmtpCredential(user.id, {
  402. id: current?.id || null,
  403. username: String(body.username || '').trim(),
  404. password: String(body.password || '')
  405. });
  406. } catch (error) {
  407. if (isUniqueError(error)) return sendJson(res, 409, { error: 'SMTP 用户名已被占用。' });
  408. throw error;
  409. }
  410. return sendJson(res, 200, { credential: getSmtpCredential(user.id, { includePassword: true }) });
  411. }
  412. if (method === 'GET' && pathname === '/api/smtp-credentials') {
  413. return sendJson(res, 200, { credentials: listSmtpCredentials(user.id, { includePassword: true }) });
  414. }
  415. if (method === 'POST' && pathname === '/api/smtp-credentials') {
  416. const body = await readJson(req);
  417. try {
  418. const credential = saveSmtpCredential(user.id, {
  419. username: String(body.username || '').trim(),
  420. password: String(body.password || '')
  421. });
  422. return sendJson(res, 201, { credential: getSmtpCredential(credential.id, user.id, { includePassword: true }) });
  423. } catch (error) {
  424. if (isUniqueError(error)) return sendJson(res, 409, { error: 'SMTP 用户名已被占用。' });
  425. throw error;
  426. }
  427. }
  428. const smtpCredentialMatch = pathname.match(/^\/api\/smtp-credentials\/(\d+)$/);
  429. if (smtpCredentialMatch) {
  430. const id = Number(smtpCredentialMatch[1]);
  431. if (method === 'GET') {
  432. const credential = getSmtpCredential(id, user.id, { includePassword: true });
  433. return sendJson(res, credential ? 200 : 404, { credential });
  434. }
  435. if (method === 'PATCH' || method === 'PUT') {
  436. const body = await readJson(req);
  437. try {
  438. const credential = saveSmtpCredential(user.id, {
  439. id,
  440. username: String(body.username || '').trim(),
  441. password: String(body.password || '')
  442. });
  443. return sendJson(res, credential ? 200 : 404, {
  444. credential: credential ? getSmtpCredential(credential.id, user.id, { includePassword: true }) : null
  445. });
  446. } catch (error) {
  447. if (isUniqueError(error)) return sendJson(res, 409, { error: 'SMTP 用户名已被占用。' });
  448. throw error;
  449. }
  450. }
  451. if (method === 'DELETE') {
  452. const deleted = deleteSmtpCredential(id, user.id);
  453. return sendJson(res, deleted ? 200 : 404, { deleted });
  454. }
  455. }
  456. if (method === 'GET' && pathname === '/api/smtp-relays') {
  457. return sendJson(res, 200, { relays: listSmtpRelays(user.id) });
  458. }
  459. if (method === 'POST' && pathname === '/api/smtp-relays') {
  460. const body = await readJson(req);
  461. const relay = saveSmtpRelay(user.id, smtpRelayPatch(body));
  462. return sendJson(res, 201, { relay });
  463. }
  464. const smtpRelayMatch = pathname.match(/^\/api\/smtp-relays\/(\d+)$/);
  465. if (smtpRelayMatch) {
  466. const id = Number(smtpRelayMatch[1]);
  467. if (method === 'GET') {
  468. const relay = getSmtpRelay(id, user.id, { includePassword: true });
  469. return sendJson(res, relay ? 200 : 404, { relay });
  470. }
  471. if (method === 'PATCH' || method === 'PUT') {
  472. const body = await readJson(req);
  473. const relay = saveSmtpRelay(user.id, { ...smtpRelayPatch(body), id });
  474. return sendJson(res, relay ? 200 : 404, { relay });
  475. }
  476. if (method === 'DELETE') {
  477. const deleted = deleteSmtpRelay(id, user.id);
  478. return sendJson(res, deleted ? 200 : 404, { deleted });
  479. }
  480. }
  481. if (method === 'POST' && pathname === '/api/send') {
  482. const body = await readJson(req);
  483. const smtpRelayId = Number(body.smtpRelayId || 0) || null;
  484. if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
  485. return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
  486. }
  487. const result = await sendMailFromBody(body, user);
  488. return sendJson(res, 202, result);
  489. }
  490. if (method === 'GET' && pathname === '/api/api-tokens') {
  491. return sendJson(res, 200, { tokens: listApiTokens(user.id) });
  492. }
  493. if (method === 'POST' && pathname === '/api/api-tokens') {
  494. const body = await readJson(req);
  495. return sendJson(res, 201, { token: createApiToken(user.id, body.name) });
  496. }
  497. const tokenMatch = pathname.match(/^\/api\/api-tokens\/(\d+)$/);
  498. if (tokenMatch && method === 'DELETE') {
  499. const deleted = deleteApiToken(Number(tokenMatch[1]), user.id);
  500. return sendJson(res, deleted ? 200 : 404, { deleted });
  501. }
  502. if (method === 'GET' && pathname === '/api/dns-credentials') {
  503. return sendJson(res, 200, { credentials: listDnsCredentials(user.id) });
  504. }
  505. if (method === 'POST' && pathname === '/api/dns-credentials') {
  506. const body = await readJson(req);
  507. const credential = saveDnsCredential(user.id, body);
  508. return sendJson(res, 201, { credential });
  509. }
  510. const dnsMatch = pathname.match(/^\/api\/dns-credentials\/(\d+)(?:\/([a-z-]+))?$/);
  511. if (dnsMatch) {
  512. const id = Number(dnsMatch[1]);
  513. const action = dnsMatch[2] || '';
  514. if ((method === 'PUT' || method === 'PATCH') && !action) {
  515. const body = await readJson(req);
  516. const credential = saveDnsCredential(user.id, { ...body, id });
  517. return sendJson(res, credential ? 200 : 404, { credential });
  518. }
  519. if (method === 'DELETE' && !action) {
  520. const deleted = deleteDnsCredential(id, user.id);
  521. return sendJson(res, deleted ? 200 : 404, { deleted });
  522. }
  523. if (method === 'POST' && action === 'test') {
  524. const credential = getDnsCredential(id, user.id, { includeCredentials: true });
  525. if (!credential) return sendJson(res, 404, { error: 'DNS 凭据不存在。' });
  526. const result = await testDnsCredential(credential);
  527. return sendJson(res, result.ok ? 200 : 400, result);
  528. }
  529. }
  530. if (method === 'GET' && pathname === '/api/webhooks') {
  531. let domainId;
  532. if (url.searchParams.has('domainId')) {
  533. const raw = url.searchParams.get('domainId');
  534. if (raw === '' || raw === 'null') {
  535. domainId = null;
  536. } else {
  537. domainId = Number(raw);
  538. if (!Number.isInteger(domainId) || domainId <= 0) {
  539. return sendJson(res, 400, { error: 'domainId 无效。' });
  540. }
  541. }
  542. }
  543. return sendJson(res, 200, { webhooks: listWebhooks(user.id, { domainId }) });
  544. }
  545. if (method === 'POST' && pathname === '/api/webhooks') {
  546. const body = await readJson(req);
  547. try {
  548. await assertSafeWebhookUrl(String(body.url || '').trim());
  549. const webhook = createWebhook(user.id, {
  550. name: body.name,
  551. url: body.url,
  552. events: body.events,
  553. domainId: body.domainId === undefined ? null : body.domainId,
  554. enabled: body.enabled
  555. });
  556. return sendJson(res, 201, { webhook });
  557. } catch (error) {
  558. return sendJson(res, 400, { error: error.message || 'Webhook 创建失败。' });
  559. }
  560. }
  561. const webhookMatch = pathname.match(/^\/api\/webhooks\/(\d+)(?:\/(rotate-secret|test))?$/);
  562. if (webhookMatch) {
  563. const id = Number(webhookMatch[1]);
  564. const action = webhookMatch[2] || '';
  565. if (method === 'PATCH' && !action) {
  566. const body = await readJson(req);
  567. try {
  568. if (body.url !== undefined) {
  569. await assertSafeWebhookUrl(String(body.url || '').trim());
  570. }
  571. const patch = {};
  572. if (body.name !== undefined) patch.name = body.name;
  573. if (body.url !== undefined) patch.url = body.url;
  574. if (body.events !== undefined) patch.events = body.events;
  575. if (body.domainId !== undefined) patch.domainId = body.domainId;
  576. if (body.enabled !== undefined) patch.enabled = body.enabled;
  577. const webhook = updateWebhook(user.id, id, patch);
  578. if (!webhook) return sendJson(res, 404, { error: 'Webhook 不存在。' });
  579. return sendJson(res, 200, { webhook });
  580. } catch (error) {
  581. return sendJson(res, 400, { error: error.message || 'Webhook 更新失败。' });
  582. }
  583. }
  584. if (method === 'DELETE' && !action) {
  585. const deleted = deleteWebhook(user.id, id);
  586. return sendJson(res, deleted ? 200 : 404, { deleted });
  587. }
  588. if (method === 'POST' && action === 'rotate-secret') {
  589. const webhook = rotateWebhookSecret(user.id, id);
  590. if (!webhook) return sendJson(res, 404, { error: 'Webhook 不存在。' });
  591. return sendJson(res, 200, { webhook });
  592. }
  593. if (method === 'POST' && action === 'test') {
  594. try {
  595. const delivery = enqueueWebhookTestDelivery(user.id, id);
  596. if (!delivery) return sendJson(res, 404, { error: 'Webhook 不存在。' });
  597. return sendJson(res, 202, { delivery });
  598. } catch (error) {
  599. return sendJson(res, 400, { error: error.message || 'Webhook 测试失败。' });
  600. }
  601. }
  602. }
  603. if (method === 'GET' && pathname === '/api/webhook-deliveries') {
  604. const filters = {};
  605. if (url.searchParams.has('status')) filters.status = url.searchParams.get('status');
  606. if (url.searchParams.has('webhookId')) filters.webhookId = Number(url.searchParams.get('webhookId'));
  607. if (url.searchParams.has('eventType')) filters.eventType = url.searchParams.get('eventType');
  608. if (url.searchParams.has('limit')) filters.limit = url.searchParams.get('limit');
  609. return sendJson(res, 200, { deliveries: listWebhookDeliveries(user.id, filters) });
  610. }
  611. const webhookDeliveryReplayMatch = pathname.match(/^\/api\/webhook-deliveries\/(\d+)\/replay$/);
  612. if (webhookDeliveryReplayMatch && method === 'POST') {
  613. try {
  614. const delivery = replayWebhookDelivery(user.id, Number(webhookDeliveryReplayMatch[1]));
  615. if (!delivery) return sendJson(res, 404, { error: 'Webhook 投递记录不存在。' });
  616. return sendJson(res, 200, { delivery });
  617. } catch (error) {
  618. return sendJson(res, 400, { error: error.message || 'Webhook 重放失败。' });
  619. }
  620. }
  621. if (pathname.startsWith('/api/admin/')) {
  622. return await handleAdminApi(req, res, url, user);
  623. }
  624. const domainMatch = pathname.match(/^\/api\/domains\/(\d+)(?:\/([a-z-]+))?$/);
  625. if (domainMatch) {
  626. const id = Number(domainMatch[1]);
  627. const action = domainMatch[2] || '';
  628. if (method === 'GET' && !action) {
  629. const domain = getDomain(id, { userId: user.id });
  630. if (!domain) return sendJson(res, 404, { error: '域名不存在。' });
  631. return sendJson(res, 200, { domain });
  632. }
  633. if (method === 'PATCH' && !action) {
  634. const body = await readJson(req);
  635. const dnsCredentialId = body.dnsCredentialId !== undefined ? Number(body.dnsCredentialId || 0) || null : undefined;
  636. if (dnsCredentialId && !getDnsCredential(dnsCredentialId, user.id)) {
  637. return sendJson(res, 400, { error: 'DNS 凭据不存在。' });
  638. }
  639. const smtpRelayId = body.smtpRelayId !== undefined ? Number(body.smtpRelayId || 0) || null : undefined;
  640. if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
  641. return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
  642. }
  643. const row = updateDomain(id, user.id, {
  644. selector: body.selector ? normalizeSelector(body.selector) : undefined,
  645. dnsCredentialId,
  646. smtpRelayId,
  647. senderHost: body.senderHost ? normalizeHostname(body.senderHost) : undefined,
  648. sendingIp: body.sendingIp !== undefined ? String(body.sendingIp).trim() : undefined,
  649. spfExtra: body.spfExtra !== undefined ? String(body.spfExtra).trim() : undefined,
  650. dmarcPolicy: body.dmarcPolicy ? normalizeDmarcPolicy(body.dmarcPolicy) : undefined,
  651. dmarcRua: body.dmarcRua !== undefined ? String(body.dmarcRua).trim() : undefined
  652. });
  653. if (!row) return sendJson(res, 404, { error: '域名不存在。' });
  654. return sendJson(res, 200, { domain: row });
  655. }
  656. if (method === 'DELETE' && !action) {
  657. const deleted = deleteDomain(id, user.id);
  658. return sendJson(res, deleted ? 200 : 404, { deleted });
  659. }
  660. if (method === 'POST' && action === 'check') {
  661. const row = getDomain(id, { userId: user.id });
  662. if (!row) return sendJson(res, 404, { error: '域名不存在。' });
  663. const guide = await buildDnsGuide(row);
  664. saveDomainStatus(id, user.id, guide);
  665. return sendJson(res, 200, { guide, domain: getDomain(id, { userId: user.id }) });
  666. }
  667. if (method === 'POST' && action === 'apply-dns') {
  668. const row = getDomain(id, { userId: user.id, includePrivate: true });
  669. if (!row) return sendJson(res, 404, { error: '域名不存在。' });
  670. const credentialId = Number(row.dnsCredentialId || 0);
  671. const credential = credentialId ? getDnsCredential(credentialId, user.id, { includeCredentials: true }) : null;
  672. if (!credential) return sendJson(res, 400, { error: '请先为该域名绑定 DNS API 凭据。' });
  673. const guide = await buildDnsGuide(row);
  674. const applyResult = await applyDnsSetup(row, credential, guide);
  675. const checkedGuide = await buildDnsGuideAfterApply(row, applyResult);
  676. checkedGuide.apply = applyResult;
  677. saveDomainStatus(id, user.id, checkedGuide);
  678. return sendJson(res, applyResult.ok ? 200 : 207, {
  679. apply: applyResult,
  680. guide: checkedGuide,
  681. domain: getDomain(id, { userId: user.id })
  682. });
  683. }
  684. if (method === 'POST' && action === 'rotate-dkim') {
  685. const row = getDomain(id, { userId: user.id });
  686. if (!row) return sendJson(res, 404, { error: '域名不存在。' });
  687. const body = await readJson(req).catch(() => ({}));
  688. const selector = normalizeSelector(body.selector || defaultSelector());
  689. const next = updateDkim(id, user.id, createDkimKeyPair(), selector);
  690. return sendJson(res, 200, { domain: next });
  691. }
  692. if (method === 'POST' && action === 'test-send') {
  693. const row = getDomain(id, { userId: user.id });
  694. if (!row) return sendJson(res, 404, { error: '域名不存在。' });
  695. const body = await readJson(req);
  696. const smtpRelayId = Number(body.smtpRelayId || 0) || null;
  697. if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
  698. return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
  699. }
  700. const from = body.from || `noreply@${row.domain}`;
  701. const result = await sendMailFromBody({
  702. from,
  703. to: body.to,
  704. subject: body.subject || `MailHub test for ${row.domain}`,
  705. text: body.text || `This is a MailHub test message from ${row.domain}.`,
  706. html: body.html,
  707. tracking: body.tracking,
  708. smtpRelayId: body.smtpRelayId
  709. }, user);
  710. return sendJson(res, 202, result);
  711. }
  712. }
  713. return sendJson(res, 404, { error: 'Not found.' });
  714. }
  715. async function handleAdminApi(req, res, url, user) {
  716. const method = req.method || 'GET';
  717. const pathname = url.pathname;
  718. if (!pathname.startsWith('/api/admin/')) return null;
  719. if (user.role !== 'admin') return sendJson(res, 403, { error: '需要管理员权限。' });
  720. if (method === 'GET' && pathname === '/api/admin/settings') {
  721. return sendJson(res, 200, { settings: await adminRuntimeSettings() });
  722. }
  723. if (method === 'GET' && pathname === '/api/admin/system-email') {
  724. return sendJson(res, 200, { settings: getSystemEmailSettings() });
  725. }
  726. if (method === 'GET' && pathname === '/api/admin/audit-logs') {
  727. return sendJson(res, 200, { logs: listAuditLogs(adminAuditFilters(url.searchParams)) });
  728. }
  729. if (method === 'GET' && pathname === '/api/admin/resources') {
  730. return sendJson(res, 200, { inventory: getAdminResourceInventory() });
  731. }
  732. const transferDomainMatch = pathname.match(/^\/api\/admin\/resources\/domains\/(\d+)\/transfer$/);
  733. if (transferDomainMatch && method === 'POST') {
  734. const body = await readJson(req);
  735. try {
  736. const domain = transferDomain({
  737. actorUserId: user.id,
  738. domainId: Number(transferDomainMatch[1]),
  739. targetUserId: body.targetUserId,
  740. dnsCredentialMode: body.dnsCredentialMode
  741. });
  742. return sendJson(res, 200, { domain });
  743. } catch (error) {
  744. return sendAdminTransferError(res, error);
  745. }
  746. }
  747. const transferDnsCredentialMatch = pathname.match(/^\/api\/admin\/resources\/dns-credentials\/(\d+)\/transfer$/);
  748. if (transferDnsCredentialMatch && method === 'POST') {
  749. const body = await readJson(req);
  750. try {
  751. const credential = transferDnsCredential({
  752. actorUserId: user.id,
  753. credentialId: Number(transferDnsCredentialMatch[1]),
  754. targetUserId: body.targetUserId
  755. });
  756. return sendJson(res, 200, { credential });
  757. } catch (error) {
  758. return sendAdminTransferError(res, error);
  759. }
  760. }
  761. if (method === 'POST' && pathname === '/api/admin/resources/api-tokens/transfer') {
  762. const body = await readJson(req);
  763. try {
  764. const tokens = transferApiTokens({
  765. actorUserId: user.id,
  766. tokenIds: body.tokenIds,
  767. targetUserId: body.targetUserId
  768. });
  769. return sendJson(res, 200, { tokens });
  770. } catch (error) {
  771. return sendAdminTransferError(res, error);
  772. }
  773. }
  774. if (method === 'POST' && pathname === '/api/admin/migrations/user-merge/preview') {
  775. const body = await readJson(req);
  776. try {
  777. const preview = previewUserMerge({
  778. sourceUserId: body.sourceUserId,
  779. targetUserId: body.targetUserId
  780. });
  781. return sendJson(res, 200, { preview });
  782. } catch (error) {
  783. return sendAdminMigrationError(res, error);
  784. }
  785. }
  786. if (method === 'POST' && pathname === '/api/admin/migrations/user-merge/execute') {
  787. const body = await readJson(req);
  788. try {
  789. const result = executeUserMerge({
  790. actorUserId: user.id,
  791. sourceUserId: body.sourceUserId,
  792. targetUserId: body.targetUserId,
  793. options: body.options,
  794. confirmation: body.confirmation
  795. });
  796. return sendJson(res, 200, { result });
  797. } catch (error) {
  798. return sendAdminMigrationError(res, error);
  799. }
  800. }
  801. if ((method === 'PATCH' || method === 'PUT') && pathname === '/api/admin/system-email') {
  802. const body = await readJson(req);
  803. const settings = saveSystemEmailSettings({
  804. host: body.host,
  805. port: body.port,
  806. secure: body.secure,
  807. username: body.username,
  808. password: body.password,
  809. helo: body.helo,
  810. fromEmail: body.fromEmail,
  811. fromName: body.fromName,
  812. testRecipient: body.testRecipient
  813. });
  814. logAudit({
  815. actorUserId: user.id,
  816. action: 'admin.update_system_email',
  817. targetType: 'system_email',
  818. targetId: 'default',
  819. summary: settings
  820. });
  821. return sendJson(res, 200, { settings });
  822. }
  823. if (method === 'POST' && pathname === '/api/admin/system-email/test') {
  824. const body = await readJson(req).catch(() => ({}));
  825. const settings = systemMailSettingsForSend();
  826. const to = extractAddress(body.to || settings.testRecipient);
  827. if (!to) return sendJson(res, 400, { error: '测试收件人地址格式不正确。' });
  828. const result = await sendSystemEmail(settings, {
  829. to,
  830. subject: 'MailHub 系统邮件测试',
  831. text: '这是一封 MailHub 系统邮件测试。'
  832. });
  833. logAudit({
  834. actorUserId: user.id,
  835. action: 'admin.test_system_email',
  836. targetType: 'system_email',
  837. targetId: 'default',
  838. summary: {
  839. to,
  840. ok: result.ok,
  841. message: result.message,
  842. queueId: result.queueId
  843. }
  844. });
  845. return sendJson(res, result.ok ? 202 : 502, { result });
  846. }
  847. if ((method === 'PATCH' || method === 'PUT') && pathname === '/api/admin/settings') {
  848. const body = await readJson(req);
  849. saveSettings(settingsPatchFromBody(body));
  850. return sendJson(res, 200, { settings: await adminRuntimeSettings() });
  851. }
  852. if (method === 'GET' && pathname === '/api/admin/users') {
  853. return sendJson(res, 200, { users: listUsersWithResourceCounts() });
  854. }
  855. const approveMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/approve$/);
  856. if (approveMatch && method === 'POST') {
  857. const current = getUser(Number(approveMatch[1]));
  858. if (!current) return sendJson(res, 404, { error: '用户不存在。' });
  859. if (current.status === 'pending_email') return sendJson(res, 400, { error: '用户尚未验证邮箱。' });
  860. if (current.status !== 'pending_review') return sendJson(res, 400, { error: '只能审批等待审核的用户。' });
  861. const target = approveUser(current.id);
  862. if (!target) return sendJson(res, 404, { error: '用户不存在。' });
  863. logAudit({
  864. actorUserId: user.id,
  865. action: 'admin.approve_user',
  866. targetType: 'user',
  867. targetId: String(target.id),
  868. targetUserId: target.id,
  869. summary: {
  870. username: target.username,
  871. status: target.status
  872. }
  873. });
  874. return sendJson(res, 200, { user: target });
  875. }
  876. const resendVerificationMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/resend-verification$/);
  877. if (resendVerificationMatch && method === 'POST') {
  878. const target = getUser(Number(resendVerificationMatch[1]));
  879. if (!target) return sendJson(res, 404, { error: '用户不存在。' });
  880. if (target.status !== 'pending_email') return sendJson(res, 400, { error: '用户不需要重新发送验证邮件。' });
  881. const result = await createAndSendVerificationEmail(target);
  882. logAudit({
  883. actorUserId: user.id,
  884. action: 'admin.resend_verification',
  885. targetType: 'user',
  886. targetId: String(target.id),
  887. targetUserId: target.id,
  888. summary: {
  889. username: target.username,
  890. email: target.email,
  891. verificationEmailSent: result.ok,
  892. message: result.message,
  893. queueId: result.queueId
  894. }
  895. });
  896. return sendJson(res, 202, verificationEmailResponse(result));
  897. }
  898. const passwordResetMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/password-reset$/);
  899. if (passwordResetMatch && method === 'POST') {
  900. const target = getUser(Number(passwordResetMatch[1]));
  901. if (!target) return sendJson(res, 404, { error: '用户不存在。' });
  902. const result = await createAndSendPasswordResetEmail(target);
  903. logAudit({
  904. actorUserId: user.id,
  905. action: 'admin.password_reset',
  906. targetType: 'user',
  907. targetId: String(target.id),
  908. targetUserId: target.id,
  909. summary: {
  910. username: target.username,
  911. email: target.email,
  912. ok: result.ok,
  913. message: result.message,
  914. queueId: result.queueId
  915. }
  916. });
  917. return sendJson(res, result.ok ? 202 : 502, { result });
  918. }
  919. const temporaryPasswordMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/temporary-password$/);
  920. if (temporaryPasswordMatch && method === 'POST') {
  921. const target = getUser(Number(temporaryPasswordMatch[1]));
  922. if (!target) return sendJson(res, 404, { error: '用户不存在。' });
  923. const body = await readJson(req);
  924. let updated;
  925. try {
  926. updated = updateUser(target.id, { password: body.password });
  927. } catch (error) {
  928. if (error?.message === '密码至少需要 8 位。') return sendJson(res, 400, { error: error.message });
  929. throw error;
  930. }
  931. logAudit({
  932. actorUserId: user.id,
  933. action: 'admin.temporary_password',
  934. targetType: 'user',
  935. targetId: String(target.id),
  936. targetUserId: target.id,
  937. summary: {
  938. username: target.username,
  939. email: target.email,
  940. passwordSet: true
  941. }
  942. });
  943. return sendJson(res, 200, { user: updated });
  944. }
  945. const userMatch = pathname.match(/^\/api\/admin\/users\/(\d+)$/);
  946. if (userMatch && method === 'PATCH') {
  947. const body = await readJson(req);
  948. let updated;
  949. try {
  950. updated = updateUser(Number(userMatch[1]), {
  951. role: body.role,
  952. status: body.status,
  953. password: body.password
  954. });
  955. } catch (error) {
  956. if (['用户状态不正确。', '密码至少需要 8 位。'].includes(error?.message)) {
  957. return sendJson(res, 400, { error: error.message });
  958. }
  959. throw error;
  960. }
  961. return sendJson(res, updated ? 200 : 404, { user: updated });
  962. }
  963. return sendJson(res, 404, { error: 'Not found.' });
  964. }
  965. function adminAuditFilters(searchParams) {
  966. const requested = {
  967. actorUserId: auditUserIdParam(searchParams.get('actorUserId'), { allowSystem: true }),
  968. targetUserId: auditUserIdParam(searchParams.get('targetUserId')),
  969. action: auditTextParam(searchParams.get('action')),
  970. from: auditDateParam(searchParams.get('from')),
  971. to: auditDateParam(searchParams.get('to'))
  972. };
  973. return Object.fromEntries(
  974. ['actorUserId', 'targetUserId', 'action', 'from', 'to']
  975. .filter((key) => requested[key] !== undefined)
  976. .map((key) => [key, requested[key]])
  977. );
  978. }
  979. function sendAdminTransferError(res, error) {
  980. if (['域名不存在。', 'DNS 凭据不存在。', 'API Token 不存在。'].includes(error?.message)) {
  981. return sendJson(res, 404, { error: error.message });
  982. }
  983. if (['目标用户不可用。', 'DNS 凭据归属不一致。'].includes(error?.message)) {
  984. return sendJson(res, 400, { error: error.message });
  985. }
  986. throw error;
  987. }
  988. function sendAdminMigrationError(res, error) {
  989. if (['源用户不存在。'].includes(error?.message)) return sendJson(res, 404, { error: error.message });
  990. if ([
  991. '目标用户不可用。',
  992. '源用户和目标用户不能相同。',
  993. '确认文本不匹配。'
  994. ].includes(error?.message)) {
  995. return sendJson(res, 400, { error: error.message });
  996. }
  997. throw error;
  998. }
  999. function auditUserIdParam(value, { allowSystem = false } = {}) {
  1000. if (value === null) return undefined;
  1001. const text = String(value).trim();
  1002. if (!text) return undefined;
  1003. if (allowSystem && ['system', 'null'].includes(text.toLowerCase())) return null;
  1004. return /^[1-9]\d*$/.test(text) ? Number(text) : undefined;
  1005. }
  1006. function auditTextParam(value) {
  1007. const text = String(value || '').trim();
  1008. return text || undefined;
  1009. }
  1010. function auditDateParam(value) {
  1011. const text = String(value || '').trim();
  1012. if (!text) return undefined;
  1013. const dateOnly = text.match(/^(\d{4})-(\d{2})-(\d{2})$/);
  1014. if (dateOnly) {
  1015. const year = Number(dateOnly[1]);
  1016. const month = Number(dateOnly[2]);
  1017. const day = Number(dateOnly[3]);
  1018. const date = new Date(Date.UTC(year, month - 1, day));
  1019. if (
  1020. date.getUTCFullYear() === year &&
  1021. date.getUTCMonth() === month - 1 &&
  1022. date.getUTCDate() === day
  1023. ) {
  1024. return date.toISOString();
  1025. }
  1026. return undefined;
  1027. }
  1028. if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(text)) return undefined;
  1029. const date = new Date(text);
  1030. return !Number.isNaN(date.getTime()) && date.toISOString() === text ? text : undefined;
  1031. }
  1032. async function sendMailFromBody(body, user) {
  1033. const settings = runtimeSettings();
  1034. const from = extractAddress(body.from);
  1035. if (!from) throw new Error('发件人地址格式不正确。');
  1036. const recipients = parseAddressList(body.to);
  1037. if (!recipients.length) throw new Error('收件人地址格式不正确。');
  1038. const fromDomain = domainFromAddress(from);
  1039. const domain = getDomainByName(fromDomain, { userId: user.id, includePrivate: true });
  1040. if (!domain) throw new Error(`发件域名 ${fromDomain} 不属于当前用户或尚未添加。`);
  1041. if (settings.sendRequiresVerified && !domain.status?.verified) {
  1042. throw new Error(`发件域名 ${fromDomain} 尚未完成验证。`);
  1043. }
  1044. const smtpTransport = smtpTransportForSend(body, domain, user);
  1045. const subject = body.subject || '(no subject)';
  1046. const tracking = resolveSendTracking(body, settings, recipients);
  1047. const openToken = tracking.opens ? createTrackingToken() : '';
  1048. const eventId = createSendEvent({
  1049. userId: user.id,
  1050. domainId: domain.id,
  1051. smtpRelayId: smtpTransport.smtpRelayId,
  1052. sender: from,
  1053. recipients,
  1054. subject,
  1055. trackingToken: openToken,
  1056. trackingOpens: tracking.opens,
  1057. trackingClicks: tracking.clicks
  1058. });
  1059. let actualTracking = {
  1060. ...tracking,
  1061. enabled: false,
  1062. opens: false,
  1063. clicks: false
  1064. };
  1065. try {
  1066. let html = body.html || '';
  1067. if (tracking.enabled) {
  1068. const result = instrumentHtml(html, {
  1069. openPixelUrl: tracking.opens ? trackingOpenUrl(settings.appBaseUrl, openToken) : '',
  1070. createClickUrl: tracking.clicks
  1071. ? (target) => createTrackedClickUrl({
  1072. appBaseUrl: settings.appBaseUrl,
  1073. userId: user.id,
  1074. eventId,
  1075. target
  1076. })
  1077. : null
  1078. });
  1079. html = result.html;
  1080. actualTracking = {
  1081. ...tracking,
  1082. enabled: (tracking.opens && result.pixelAdded) || (tracking.clicks && result.linkCount > 0),
  1083. opens: tracking.opens && result.pixelAdded,
  1084. clicks: tracking.clicks && result.linkCount > 0
  1085. };
  1086. }
  1087. const rawMessage = buildMessage({
  1088. from,
  1089. to: recipients,
  1090. subject,
  1091. text: body.text || '',
  1092. html,
  1093. baseUrl: settings.appBaseUrl,
  1094. headers: buildDeliverabilityHeaders({
  1095. from,
  1096. listUnsubscribeMailto: settings.listUnsubscribeMailto,
  1097. listUnsubscribeUrl: settings.listUnsubscribeUrl,
  1098. listUnsubscribePostEnabled: settings.listUnsubscribePostEnabled,
  1099. feedbackId: settings.feedbackIdEnabled
  1100. ? createFeedbackId({
  1101. userId: user.id,
  1102. domainId: domain.id,
  1103. eventId,
  1104. secret: envConfig.trackingSecret
  1105. })
  1106. : '',
  1107. reportAbuseTo: settings.reportAbuseTo,
  1108. csaComplaintsTo: settings.csaComplaintsTo,
  1109. context: {
  1110. eventId,
  1111. userId: user.id,
  1112. domain: fromDomain,
  1113. sender: from,
  1114. recipient: recipients.length === 1 ? recipients[0] : ''
  1115. }
  1116. })
  1117. });
  1118. const signed = signMessageForDomain(rawMessage, domain);
  1119. const smtpResult = await sendViaSmtp({
  1120. host: smtpTransport.host,
  1121. port: smtpTransport.port,
  1122. secure: smtpTransport.secure,
  1123. username: smtpTransport.username,
  1124. password: smtpTransport.password,
  1125. helo: smtpTransport.helo,
  1126. mailFrom: resolveEnvelopeSender(settings, from),
  1127. recipients,
  1128. rawMessage: signed
  1129. });
  1130. finalizeSendEvent(eventId, user.id, {
  1131. smtpRelayId: smtpTransport.smtpRelayId,
  1132. status: 'queued',
  1133. detail: smtpResult.message,
  1134. queueId: smtpResult.queueId,
  1135. deliveryLog: smtpResult.deliveryLog,
  1136. trackingOpens: actualTracking.opens,
  1137. trackingClicks: actualTracking.clicks
  1138. });
  1139. return {
  1140. eventId,
  1141. queued: true,
  1142. domain: domain.domain,
  1143. recipients,
  1144. smtp: smtpResult.message,
  1145. queueId: smtpResult.queueId,
  1146. smtpRelayId: smtpTransport.smtpRelayId,
  1147. tracking: actualTracking
  1148. };
  1149. } catch (error) {
  1150. finalizeSendEvent(eventId, user.id, {
  1151. smtpRelayId: smtpTransport.smtpRelayId,
  1152. status: 'failed',
  1153. detail: error.message,
  1154. deliveryLog: deliveryLogFromError(error),
  1155. trackingOpens: actualTracking.opens,
  1156. trackingClicks: actualTracking.clicks
  1157. });
  1158. throw error;
  1159. }
  1160. }
  1161. function resolveSendTracking(body, settings, recipients) {
  1162. if (!body.html) return { enabled: false, opens: false, clicks: false, messageLevel: recipients.length > 1 };
  1163. const configured = Boolean(settings.engagementTrackingEnabled);
  1164. const requested = body.tracking;
  1165. let opens = configured;
  1166. let clicks = configured;
  1167. if (typeof requested === 'boolean') {
  1168. opens = requested;
  1169. clicks = requested;
  1170. } else if (requested && typeof requested === 'object') {
  1171. if (Object.hasOwn(requested, 'opens')) opens = Boolean(requested.opens);
  1172. if (Object.hasOwn(requested, 'clicks')) clicks = Boolean(requested.clicks);
  1173. }
  1174. return {
  1175. enabled: opens || clicks,
  1176. opens,
  1177. clicks,
  1178. messageLevel: recipients.length > 1
  1179. };
  1180. }
  1181. function createTrackedClickUrl({ appBaseUrl, userId, eventId, target }) {
  1182. const normalizedTarget = new URL(target).toString();
  1183. const token = createTrackingToken();
  1184. createTrackingLink(userId, eventId, {
  1185. token,
  1186. targetCiphertext: encryptTrackingTarget(normalizedTarget, envConfig.trackingSecret),
  1187. targetFingerprint: trackingTargetFingerprint(normalizedTarget, envConfig.trackingSecret),
  1188. targetOrigin: new URL(normalizedTarget).origin
  1189. });
  1190. return `${trackingUrlBase(appBaseUrl)}/t/c/${token}`;
  1191. }
  1192. function trackingOpenUrl(appBaseUrl, token) {
  1193. return `${trackingUrlBase(appBaseUrl)}/t/o/${token}.gif`;
  1194. }
  1195. function trackingUrlBase(appBaseUrl) {
  1196. return String(appBaseUrl || '').replace(/\/+$/, '');
  1197. }
  1198. function deliveryLogFromError(error) {
  1199. if (Array.isArray(error?.deliveryLog)) return error.deliveryLog;
  1200. return [{
  1201. at: new Date().toISOString(),
  1202. phase: 'error',
  1203. direction: 'system',
  1204. message: error?.message || 'Unknown SMTP delivery error',
  1205. ok: false
  1206. }];
  1207. }
  1208. function smtpTransportForSend(body, domain, user) {
  1209. const requestedRelayId = Number(body.smtpRelayId || 0) || null;
  1210. if (requestedRelayId) {
  1211. const relay = getSmtpRelay(requestedRelayId, user.id, { includePassword: true });
  1212. if (!relay) throw new Error('SMTP 出口不存在。');
  1213. return smtpTransportFromRelay(relay);
  1214. }
  1215. if (domain.smtpRelayId) {
  1216. const relay = getSmtpRelay(domain.smtpRelayId, user.id, { includePassword: true });
  1217. if (!relay) throw new Error('SMTP 出口不存在。');
  1218. return smtpTransportFromRelay(relay);
  1219. }
  1220. const defaultRelay = getDefaultSmtpRelay(user.id, { includePassword: true });
  1221. if (defaultRelay) return smtpTransportFromRelay(defaultRelay);
  1222. return {
  1223. smtpRelayId: null,
  1224. host: envConfig.smtpHost,
  1225. port: envConfig.smtpPort,
  1226. secure: envConfig.smtpSecure,
  1227. username: envConfig.smtpUser,
  1228. password: envConfig.smtpPassword,
  1229. helo: envConfig.smtpHelo
  1230. };
  1231. }
  1232. function smtpTransportFromRelay(relay) {
  1233. return {
  1234. smtpRelayId: relay.id,
  1235. host: relay.host,
  1236. port: relay.port,
  1237. secure: relay.secure,
  1238. username: relay.username,
  1239. password: relay.password || '',
  1240. helo: relay.helo || envConfig.smtpHelo
  1241. };
  1242. }
  1243. function runBackground(promise) {
  1244. promise.catch((error) => console.error(error));
  1245. }
  1246. async function handleRegister(req, res) {
  1247. const body = await readJson(req);
  1248. try {
  1249. const { user, accountToken } = createUserWithAccountToken({
  1250. username: body.username,
  1251. email: body.email,
  1252. password: body.password,
  1253. status: 'pending_email'
  1254. }, emailVerificationPurpose, { ttlMinutes: 24 * 60 });
  1255. const emailResult = await sendVerificationEmail(user, accountToken.token);
  1256. return sendRegisterSuccess(req, res, user, emailResult);
  1257. } catch (error) {
  1258. if (isUniqueError(error)) return sendAuthError(req, res, 409, '用户名或邮箱已被注册。', '/register');
  1259. return sendAuthError(req, res, 400, error.message || '注册失败。', '/register');
  1260. }
  1261. }
  1262. async function handleResendVerification(req, res) {
  1263. const body = await readJson(req);
  1264. const user = getUserByLogin(body.email);
  1265. if (user?.status === 'pending_email') {
  1266. runBackground(createAndSendVerificationEmail(user));
  1267. }
  1268. return sendJson(res, 202, publicVerificationResendResponse());
  1269. }
  1270. async function handleForgotPassword(req, res) {
  1271. const body = await readJson(req);
  1272. const user = getUserByLogin(body.email);
  1273. if (user) runBackground(createAndSendPasswordResetEmail(user));
  1274. return sendJson(res, 202, publicForgotPasswordResponse());
  1275. }
  1276. async function handleResetPassword(req, res) {
  1277. const body = await readJson(req);
  1278. if (String(body.password || '').length < 8) return sendJson(res, 400, { error: '密码至少需要 8 位。' });
  1279. const consumed = consumeAccountToken(body.token, passwordResetPurpose);
  1280. if (!consumed) return sendJson(res, 400, { error: '重置链接无效或已过期。' });
  1281. updateUser(consumed.userId, { password: body.password });
  1282. return sendJson(res, 200, { message: '密码已重置,请使用新密码登录。' });
  1283. }
  1284. async function handleVerifyEmail(req, res, url) {
  1285. if ((req.method || 'GET') !== 'GET') return sendJson(res, 404, { error: 'Not found.' });
  1286. const token = String(url.searchParams.get('token') || '').trim();
  1287. if (!token) return sendJson(res, 400, { error: '验证链接无效或已过期。' });
  1288. const consumed = consumeAccountToken(token, emailVerificationPurpose);
  1289. if (!consumed) return sendJson(res, 400, { error: '验证链接无效或已过期。' });
  1290. const user = markUserEmailVerified(consumed.userId);
  1291. if (!user) return sendJson(res, 400, { error: '验证链接无效或已过期。' });
  1292. return sendJson(res, 200, {
  1293. user,
  1294. message: '邮箱验证成功,请等待管理员审核。'
  1295. });
  1296. }
  1297. async function createAndSendVerificationEmail(user) {
  1298. const settings = systemMailSettingsForSend();
  1299. if (!systemMailConfigured(settings)) return { ok: false, message: '系统邮件未配置。' };
  1300. invalidateAccountTokens(user.id, emailVerificationPurpose);
  1301. const accountToken = createAccountToken(user.id, emailVerificationPurpose, { ttlMinutes: 24 * 60 });
  1302. const result = await sendVerificationEmailWithSettings(user, accountToken.token, settings);
  1303. if (!result.ok) invalidateAccountTokens(user.id, emailVerificationPurpose);
  1304. return result;
  1305. }
  1306. async function createAndSendPasswordResetEmail(user) {
  1307. const settings = systemMailSettingsForSend();
  1308. if (!systemMailConfigured(settings)) return { ok: false, message: '系统邮件未配置。' };
  1309. invalidateAccountTokens(user.id, passwordResetPurpose);
  1310. const accountToken = createAccountToken(user.id, passwordResetPurpose, { ttlMinutes: 60 });
  1311. const result = await sendSystemEmail(settings, buildPasswordResetEmail({
  1312. appBaseUrl: settings.appBaseUrl,
  1313. to: user.email,
  1314. token: accountToken.token,
  1315. fromEmail: settings.fromEmail,
  1316. fromName: settings.fromName
  1317. }));
  1318. if (!result.ok) invalidateAccountTokens(user.id, passwordResetPurpose);
  1319. return result;
  1320. }
  1321. async function sendVerificationEmail(user, token) {
  1322. const settings = systemMailSettingsForSend();
  1323. if (!systemMailConfigured(settings)) {
  1324. return { ok: false, message: '系统邮件未配置。' };
  1325. }
  1326. return await sendVerificationEmailWithSettings(user, token, settings);
  1327. }
  1328. async function sendVerificationEmailWithSettings(user, token, settings) {
  1329. return await sendSystemEmail(settings, buildVerificationEmail({
  1330. appBaseUrl: settings.appBaseUrl,
  1331. to: user.email,
  1332. token,
  1333. fromEmail: settings.fromEmail,
  1334. fromName: settings.fromName
  1335. }));
  1336. }
  1337. function systemMailConfigured(settings) {
  1338. return Boolean(settings.host && extractAddress(settings.fromEmail));
  1339. }
  1340. function systemMailSettingsForSend() {
  1341. return {
  1342. ...getSystemEmailSettings({ includeSecret: true }),
  1343. appBaseUrl: runtimeSettings().appBaseUrl
  1344. };
  1345. }
  1346. function publicVerificationResendResponse() {
  1347. return {
  1348. message: '如果账号需要验证,我们会发送验证邮件。'
  1349. };
  1350. }
  1351. function publicForgotPasswordResponse() {
  1352. return {
  1353. message: '如果邮箱存在,我们会发送密码重置邮件。'
  1354. };
  1355. }
  1356. function verificationEmailResponse(result) {
  1357. return {
  1358. verificationEmailSent: Boolean(result.ok),
  1359. message: result.ok ? '验证邮件已发送。' : '验证邮件暂未发送,请稍后重试或联系管理员。',
  1360. result: {
  1361. ok: Boolean(result.ok),
  1362. message: result.message || '',
  1363. queueId: result.queueId || ''
  1364. }
  1365. };
  1366. }
  1367. async function handleLogin(req, res) {
  1368. const body = await readJson(req);
  1369. const user = verifyUserCredentials(body.username || body.email, body.password);
  1370. if (!user) return sendAuthError(req, res, 401, '账号或密码不正确。', '/login');
  1371. if (user.status !== 'active') return sendAuthError(req, res, 403, loginStatusMessage(user.status), '/login');
  1372. return sendAuthSuccess(req, res, 200, user);
  1373. }
  1374. function sendRegisterSuccess(req, res, user, emailResult = { ok: false }) {
  1375. const message = emailResult.ok
  1376. ? '注册成功,验证邮件已发送,请先验证邮箱,验证后等待管理员审核。'
  1377. : '注册成功,请先验证邮箱;验证邮件暂未发送,请联系管理员或稍后重试。';
  1378. if (wantsHtmlRedirect(req)) return redirect(res, `/login?error=${encodeURIComponent(message)}`, 303);
  1379. return sendJson(res, 201, {
  1380. user,
  1381. message,
  1382. verificationEmailSent: Boolean(emailResult.ok)
  1383. });
  1384. }
  1385. function sendAuthSuccess(req, res, status, user) {
  1386. const token = createSessionToken(user);
  1387. const cookie = sessionCookie(token);
  1388. if (wantsHtmlRedirect(req)) return redirect(res, '/', 303, { 'Set-Cookie': cookie });
  1389. res.writeHead(status, {
  1390. 'Content-Type': 'application/json; charset=utf-8',
  1391. 'Set-Cookie': cookie
  1392. });
  1393. res.end(JSON.stringify({ user }));
  1394. }
  1395. function sendAuthError(req, res, status, message, fallbackPath) {
  1396. if (wantsHtmlRedirect(req)) return redirect(res, `${fallbackPath}?error=${encodeURIComponent(message)}`, 303);
  1397. return sendJson(res, status, { error: message });
  1398. }
  1399. function loginStatusMessage(status) {
  1400. if (status === 'pending_email') return '请先验证邮箱。';
  1401. if (status === 'pending_review') return '账号正在等待管理员审核。';
  1402. if (status === 'disabled') return '账号已被禁用。';
  1403. return '账号或密码不正确。';
  1404. }
  1405. function handleLogout(res) {
  1406. res.writeHead(200, {
  1407. 'Content-Type': 'application/json; charset=utf-8',
  1408. 'Set-Cookie': 'mailhub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'
  1409. });
  1410. res.end(JSON.stringify({ ok: true }));
  1411. }
  1412. function getRequestUser(req, pathname) {
  1413. const sessionUser = getSessionUser(req);
  1414. if (sessionUser) return sessionUser;
  1415. const auth = req.headers.authorization || '';
  1416. if (auth.startsWith('Basic ')) {
  1417. const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
  1418. const index = decoded.indexOf(':');
  1419. const user = authenticateUser(decoded.slice(0, index), decoded.slice(index + 1));
  1420. if (user) return user;
  1421. }
  1422. if (pathname === '/api/send' && auth.startsWith('Bearer ')) {
  1423. const token = auth.slice(7);
  1424. const user = verifyApiToken(token);
  1425. if (user) return user;
  1426. if (envConfig.legacyApiToken && safeEqual(token, envConfig.legacyApiToken)) return getAdminUser();
  1427. }
  1428. return null;
  1429. }
  1430. function getSessionUser(req) {
  1431. const token = parseCookies(req.headers.cookie || '').mailhub_session;
  1432. if (!token || !token.includes('.')) return null;
  1433. const [payload, signature] = token.split('.');
  1434. if (!payload || !signature || !safeEqual(signature, signSessionPayload(payload))) return null;
  1435. try {
  1436. const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
  1437. if (Number(data.exp) <= Date.now()) return null;
  1438. const user = getUser(Number(data.uid));
  1439. return user?.status === 'active' ? user : null;
  1440. } catch {
  1441. return null;
  1442. }
  1443. }
  1444. function createSessionToken(user) {
  1445. const payload = Buffer.from(JSON.stringify({
  1446. uid: user.id,
  1447. exp: Date.now() + 12 * 60 * 60 * 1000,
  1448. nonce: crypto.randomBytes(10).toString('hex')
  1449. })).toString('base64url');
  1450. return `${payload}.${signSessionPayload(payload)}`;
  1451. }
  1452. function signSessionPayload(payload) {
  1453. return crypto.createHmac('sha256', envConfig.sessionSecret).update(payload).digest('base64url');
  1454. }
  1455. function sessionCookie(token) {
  1456. return [`mailhub_session=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=43200'].join('; ');
  1457. }
  1458. function publicConfig(user) {
  1459. const settings = runtimeSettings();
  1460. const smtpCredential = getSmtpCredential(user.id);
  1461. return {
  1462. ...settings,
  1463. smtpHost: envConfig.smtpHost ? 'configured' : '',
  1464. submission: {
  1465. enabled: envConfig.submissionEnabled,
  1466. host: envConfig.submissionHost,
  1467. ports: publicSubmissionListeners(envConfig.submissionListeners),
  1468. username: smtpCredential?.username || '',
  1469. passwordSet: Boolean(smtpCredential?.passwordSet),
  1470. inboundEnabled: envConfig.inboundEnabled,
  1471. tls: Boolean(envConfig.submissionTlsCert && envConfig.submissionTlsKey),
  1472. requireTlsForAuth: !envConfig.submissionAllowInsecureAuth
  1473. },
  1474. apiTokenSet: Boolean(envConfig.legacyApiToken),
  1475. usingDefaultAdminPassword: user.role === 'admin' && envConfig.adminPassword === 'change-this-admin-password'
  1476. };
  1477. }
  1478. function runtimeSettings() {
  1479. const settings = getSettings(defaultSettings);
  1480. return {
  1481. appBaseUrl: settings.appBaseUrl,
  1482. mailHostname: settings.mailHostname,
  1483. sendingIp: settings.sendingIp,
  1484. defaultSpfMechanisms: settings.defaultSpfMechanisms,
  1485. dmarcPolicy: normalizeDmarcPolicy(settings.dmarcPolicy),
  1486. dmarcRua: settings.dmarcRua,
  1487. sendRequiresVerified: String(settings.sendRequiresVerified).toLowerCase() === 'true',
  1488. engagementTrackingEnabled: String(settings.engagementTrackingEnabled).toLowerCase() === 'true',
  1489. listUnsubscribeMailto: settings.listUnsubscribeMailto,
  1490. listUnsubscribeUrl: settings.listUnsubscribeUrl,
  1491. listUnsubscribePostEnabled: String(settings.listUnsubscribePostEnabled).toLowerCase() === 'true',
  1492. feedbackIdEnabled: String(settings.feedbackIdEnabled).toLowerCase() !== 'false',
  1493. reportAbuseTo: settings.reportAbuseTo,
  1494. csaComplaintsTo: settings.csaComplaintsTo,
  1495. bounceAddress: settings.bounceAddress,
  1496. bounceEnvelopeEnabled: String(settings.bounceEnvelopeEnabled).toLowerCase() === 'true'
  1497. };
  1498. }
  1499. async function adminRuntimeSettings() {
  1500. const settings = runtimeSettings();
  1501. return {
  1502. ...settings,
  1503. systemChecks: await buildSystemDnsChecks(settings)
  1504. };
  1505. }
  1506. async function serveStatic(req, res, url) {
  1507. const publicDir = path.join(__dirname, '..', 'public');
  1508. const pathname = decodeURIComponent(resolveStaticPathname(url.pathname));
  1509. const filePath = path.normalize(path.join(publicDir, pathname));
  1510. if (!filePath.startsWith(publicDir) || !existsSync(filePath) || statSync(filePath).isDirectory()) {
  1511. return sendStaticFile(res, path.join(publicDir, 'index.html'), { noStore: true });
  1512. }
  1513. const noStore = path.extname(filePath) === '.html';
  1514. return sendStaticFile(res, filePath, { noStore });
  1515. }
  1516. async function sendStaticFile(res, filePath, { noStore = false } = {}) {
  1517. const ext = path.extname(filePath);
  1518. const contentType = {
  1519. '.html': 'text/html; charset=utf-8',
  1520. '.css': 'text/css; charset=utf-8',
  1521. '.js': 'application/javascript; charset=utf-8',
  1522. '.json': 'application/json; charset=utf-8',
  1523. '.svg': 'image/svg+xml'
  1524. }[ext] || 'application/octet-stream';
  1525. const headers = { 'Content-Type': contentType };
  1526. if (noStore || ext === '.html') {
  1527. headers['Cache-Control'] = 'private, no-store';
  1528. }
  1529. res.writeHead(200, headers);
  1530. res.end(await readFile(filePath));
  1531. }
  1532. async function readJson(req) {
  1533. const chunks = [];
  1534. for await (const chunk of req) chunks.push(chunk);
  1535. if (!chunks.length) return {};
  1536. const raw = Buffer.concat(chunks).toString('utf8');
  1537. const contentType = String(req.headers['content-type'] || '').toLowerCase();
  1538. if (contentType.includes('application/x-www-form-urlencoded')) {
  1539. return Object.fromEntries(new URLSearchParams(raw).entries());
  1540. }
  1541. return JSON.parse(raw);
  1542. }
  1543. const trackingPixel = Buffer.from(
  1544. 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
  1545. 'base64'
  1546. );
  1547. async function handleTrackingRequest(req, res, url) {
  1548. const openMatch = url.pathname.match(/^\/t\/o\/([A-Za-z0-9_-]{20,128})\.gif$/);
  1549. if (openMatch) {
  1550. if (!['GET', 'HEAD'].includes(req.method)) return sendTrackingStatus(res, 405, 'Method not allowed.');
  1551. if (req.method === 'GET') {
  1552. const sendEvent = findSendEventByTrackingToken(openMatch[1]);
  1553. if (sendEvent?.tracking?.opens) {
  1554. recordPublicTrackingEvent(req, {
  1555. sendEvent,
  1556. eventType: 'open'
  1557. });
  1558. }
  1559. }
  1560. return sendTrackingPixel(res, req.method === 'HEAD');
  1561. }
  1562. if (url.pathname.startsWith('/t/o/')) {
  1563. if (!['GET', 'HEAD'].includes(req.method)) return sendTrackingStatus(res, 405, 'Method not allowed.');
  1564. return sendTrackingPixel(res, req.method === 'HEAD');
  1565. }
  1566. const clickMatch = url.pathname.match(/^\/t\/c\/([A-Za-z0-9_-]{20,128})$/);
  1567. if (!clickMatch) {
  1568. if (url.pathname.startsWith('/t/c/')) return sendTrackingStatus(res, 404, 'Not found.');
  1569. return false;
  1570. }
  1571. if (req.method !== 'GET') return sendTrackingStatus(res, 405, 'Method not allowed.');
  1572. const link = findTrackingLinkByToken(clickMatch[1]);
  1573. if (!link?.trackingClicks) return sendTrackingStatus(res, 404, 'Not found.');
  1574. let target;
  1575. try {
  1576. target = decryptTrackingTarget(link.targetCiphertext, envConfig.trackingSecret);
  1577. } catch {
  1578. return sendTrackingStatus(res, 410, 'Link is no longer available.');
  1579. }
  1580. recordPublicTrackingEvent(req, {
  1581. sendEvent: {
  1582. id: link.sendEventId,
  1583. userId: link.userId
  1584. },
  1585. eventType: 'click',
  1586. trackingLinkId: link.id
  1587. });
  1588. res.writeHead(302, {
  1589. Location: target,
  1590. 'Cache-Control': 'private, no-store, no-cache, max-age=0',
  1591. Pragma: 'no-cache',
  1592. 'Referrer-Policy': 'no-referrer'
  1593. });
  1594. res.end();
  1595. return true;
  1596. }
  1597. function recordPublicTrackingEvent(req, { sendEvent, eventType, trackingLinkId = null }) {
  1598. const occurredAt = new Date().toISOString();
  1599. const userAgent = String(req.headers['user-agent'] || '').slice(0, 500);
  1600. const source = classifyTrackingSource(userAgent);
  1601. const ipHash = hashTrackingClientIp({
  1602. ip: requestClientIp(req),
  1603. secret: envConfig.trackingSecret,
  1604. userId: sendEvent.userId,
  1605. sendEventId: sendEvent.id,
  1606. occurredAt
  1607. });
  1608. const replayKey = trackingReplayKey({
  1609. secret: envConfig.trackingSecret,
  1610. sendEventId: sendEvent.id,
  1611. eventType,
  1612. trackingLinkId,
  1613. ipHash,
  1614. userAgent,
  1615. occurredAt
  1616. });
  1617. try {
  1618. recordTrackingEvent({
  1619. sendEventId: sendEvent.id,
  1620. trackingLinkId,
  1621. eventType,
  1622. source,
  1623. occurredAt,
  1624. userAgent,
  1625. ipHash,
  1626. replayKey
  1627. });
  1628. } catch (error) {
  1629. console.warn(`Tracking event could not be recorded: ${error.message}`);
  1630. }
  1631. }
  1632. function requestClientIp(req) {
  1633. if (envConfig.trustProxy) {
  1634. const forwarded = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
  1635. if (forwarded) return forwarded;
  1636. }
  1637. return req.socket?.remoteAddress || '';
  1638. }
  1639. function sendTrackingPixel(res, headOnly = false) {
  1640. res.writeHead(200, {
  1641. 'Content-Type': 'image/gif',
  1642. 'Content-Length': String(trackingPixel.length),
  1643. 'Cache-Control': 'private, no-store, no-cache, max-age=0',
  1644. Pragma: 'no-cache',
  1645. Expires: '0',
  1646. 'Referrer-Policy': 'no-referrer'
  1647. });
  1648. res.end(headOnly ? undefined : trackingPixel);
  1649. return true;
  1650. }
  1651. function sendTrackingStatus(res, status, message) {
  1652. res.writeHead(status, {
  1653. 'Content-Type': 'text/plain; charset=utf-8',
  1654. 'Cache-Control': 'private, no-store, no-cache, max-age=0',
  1655. Pragma: 'no-cache',
  1656. 'Referrer-Policy': 'no-referrer'
  1657. });
  1658. res.end(message);
  1659. return true;
  1660. }
  1661. function sendJson(res, status, payload) {
  1662. res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
  1663. res.end(JSON.stringify(payload));
  1664. }
  1665. function redirect(res, location, status = 302, headers = {}) {
  1666. res.writeHead(status, { ...headers, Location: location });
  1667. res.end();
  1668. }
  1669. function wantsHtmlRedirect(req) {
  1670. const contentType = String(req.headers['content-type'] || '').toLowerCase();
  1671. const accept = String(req.headers.accept || '').toLowerCase();
  1672. return contentType.includes('application/x-www-form-urlencoded') && accept.includes('text/html');
  1673. }
  1674. function setSecurityHeaders(res) {
  1675. res.setHeader('X-Content-Type-Options', 'nosniff');
  1676. res.setHeader('X-Frame-Options', 'DENY');
  1677. res.setHeader('Referrer-Policy', 'same-origin');
  1678. }
  1679. function handleOptions(res) {
  1680. res.writeHead(204, {
  1681. 'Access-Control-Allow-Origin': '*',
  1682. 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
  1683. 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
  1684. });
  1685. res.end();
  1686. }
  1687. function parseCookies(header) {
  1688. const cookies = {};
  1689. for (const part of String(header || '').split(';')) {
  1690. const index = part.indexOf('=');
  1691. if (index === -1) continue;
  1692. cookies[part.slice(0, index).trim()] = part.slice(index + 1).trim();
  1693. }
  1694. return cookies;
  1695. }
  1696. function safeEqual(actual, expected) {
  1697. const a = Buffer.from(String(actual || ''));
  1698. const b = Buffer.from(String(expected || ''));
  1699. if (a.length !== b.length) return false;
  1700. return crypto.timingSafeEqual(a, b);
  1701. }
  1702. function normalizeDomain(input) {
  1703. const raw = String(input || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/\.$/, '');
  1704. const ascii = domainToASCII(raw);
  1705. if (!ascii || ascii.length > 253) return '';
  1706. if (!/^(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/.test(ascii)) return '';
  1707. return ascii;
  1708. }
  1709. function normalizeHostname(input) {
  1710. return normalizeDomain(input) || String(input || '').trim().toLowerCase();
  1711. }
  1712. function normalizeSelector(input) {
  1713. const value = String(input || '').trim().toLowerCase();
  1714. return /^[a-z0-9][a-z0-9-]{0,62}$/.test(value) ? value : '';
  1715. }
  1716. function normalizeDmarcPolicy(input) {
  1717. const value = String(input || '').trim().toLowerCase();
  1718. return ['none', 'quarantine', 'reject'].includes(value) ? value : 'none';
  1719. }
  1720. function smtpRelayPatch(body) {
  1721. const patch = {};
  1722. for (const key of ['name', 'host', 'port', 'secure', 'username', 'password', 'helo', 'isDefault']) {
  1723. if (Object.hasOwn(body, key)) patch[key] = body[key];
  1724. }
  1725. return patch;
  1726. }
  1727. function settingsPatchFromBody(body) {
  1728. const patch = {};
  1729. for (const key of [
  1730. 'appBaseUrl',
  1731. 'mailHostname',
  1732. 'sendingIp',
  1733. 'defaultSpfMechanisms',
  1734. 'dmarcRua',
  1735. 'listUnsubscribeMailto',
  1736. 'listUnsubscribeUrl',
  1737. 'reportAbuseTo',
  1738. 'csaComplaintsTo',
  1739. 'bounceAddress'
  1740. ]) {
  1741. if (Object.hasOwn(body, key)) patch[key] = body[key];
  1742. }
  1743. if (Object.hasOwn(body, 'dmarcPolicy')) patch.dmarcPolicy = normalizeDmarcPolicy(body.dmarcPolicy);
  1744. if (Object.hasOwn(body, 'sendRequiresVerified')) patch.sendRequiresVerified = boolString(body.sendRequiresVerified);
  1745. if (Object.hasOwn(body, 'engagementTrackingEnabled')) {
  1746. patch.engagementTrackingEnabled = boolString(body.engagementTrackingEnabled);
  1747. }
  1748. if (Object.hasOwn(body, 'listUnsubscribePostEnabled')) {
  1749. patch.listUnsubscribePostEnabled = boolString(body.listUnsubscribePostEnabled);
  1750. }
  1751. if (Object.hasOwn(body, 'feedbackIdEnabled')) {
  1752. patch.feedbackIdEnabled = boolString(body.feedbackIdEnabled);
  1753. }
  1754. if (Object.hasOwn(body, 'bounceEnvelopeEnabled')) {
  1755. patch.bounceEnvelopeEnabled = boolString(body.bounceEnvelopeEnabled);
  1756. }
  1757. return patch;
  1758. }
  1759. function boolString(value) {
  1760. return String(value).toLowerCase() === 'true' || value === true ? 'true' : 'false';
  1761. }
  1762. function defaultSelector() {
  1763. const d = new Date();
  1764. return `mh${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
  1765. }
  1766. async function buildDnsGuideAfterApply(domain, applyResult) {
  1767. const appliedKeys = new Set((applyResult.results || [])
  1768. .filter((result) => result.ok && !result.skipped)
  1769. .map((result) => result.key));
  1770. let guide = await buildDnsGuide(domain);
  1771. for (let attempt = 0; attempt < 2 && hasUnpropagatedAppliedRecords(guide, appliedKeys); attempt += 1) {
  1772. await sleep(1800);
  1773. guide = await buildDnsGuide(domain);
  1774. }
  1775. return markUnpropagatedAppliedRecords(guide, appliedKeys);
  1776. }
  1777. function hasUnpropagatedAppliedRecords(guide, appliedKeys) {
  1778. return (guide.records || []).some((record) => appliedKeys.has(record.key) && record.status !== 'ok');
  1779. }
  1780. function markUnpropagatedAppliedRecords(guide, appliedKeys) {
  1781. const records = (guide.records || []).map((record) => {
  1782. if (!appliedKeys.has(record.key) || record.status === 'ok') return record;
  1783. return {
  1784. ...record,
  1785. status: 'pending',
  1786. warnings: [
  1787. ...(record.warnings || []),
  1788. '已提交到 DNS 服务商,正在等待公共 DNS 传播;稍后点击“立即检查”刷新。'
  1789. ]
  1790. };
  1791. });
  1792. return {
  1793. ...guide,
  1794. records,
  1795. warnings: collectGuideWarnings(records)
  1796. };
  1797. }
  1798. function collectGuideWarnings(records) {
  1799. return records.flatMap((record) => record.warnings || []);
  1800. }
  1801. function sleep(ms) {
  1802. return new Promise((resolve) => setTimeout(resolve, ms));
  1803. }
  1804. function isUniqueError(error) {
  1805. return /UNIQUE constraint failed/i.test(String(error?.message || ''));
  1806. }
  1807. function isLoginAsset(pathname) {
  1808. return pathname.startsWith('/assets/')
  1809. || [
  1810. '/login',
  1811. '/register',
  1812. '/forgot-password',
  1813. '/resend-verification',
  1814. '/reset-password',
  1815. '/login.html',
  1816. '/login.css',
  1817. '/login.js',
  1818. '/landing.html'
  1819. ].includes(pathname);
  1820. }
  1821. function resolveStaticPathname(pathname) {
  1822. if (pathname === '/') return '/landing.html';
  1823. if (['/login', '/register', '/forgot-password', '/resend-verification', '/reset-password'].includes(pathname)) return '/login.html';
  1824. return pathname;
  1825. }
  1826. function loadDotEnv() {
  1827. const file = path.join(process.cwd(), '.env');
  1828. if (!existsSync(file)) return;
  1829. const lines = readFileSync(file, 'utf8').split(/\r?\n/);
  1830. for (const line of lines) {
  1831. const trimmed = line.trim();
  1832. if (!trimmed || trimmed.startsWith('#')) continue;
  1833. const index = trimmed.indexOf('=');
  1834. if (index === -1) continue;
  1835. const key = trimmed.slice(0, index).trim();
  1836. let value = trimmed.slice(index + 1).trim();
  1837. if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
  1838. if (!(key in process.env)) process.env[key] = value;
  1839. }
  1840. }