app.js 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869
  1. const state = {
  2. me: null,
  3. config: null,
  4. smtpCredential: null,
  5. dnsCredentials: [],
  6. apiTokens: [],
  7. domains: [],
  8. events: [],
  9. users: [],
  10. settings: null,
  11. selectedId: null,
  12. busy: false
  13. };
  14. const els = {
  15. runtimeLine: document.querySelector('#runtimeLine'),
  16. securityNotice: document.querySelector('#securityNotice'),
  17. accountBox: document.querySelector('#accountBox'),
  18. userRole: document.querySelector('#userRole'),
  19. addDomainForm: document.querySelector('#addDomainForm'),
  20. domainDnsCredential: document.querySelector('#domainDnsCredential'),
  21. smtpCredentialForm: document.querySelector('#smtpCredentialForm'),
  22. smtpCredentialState: document.querySelector('#smtpCredentialState'),
  23. smtpCredentialCopy: document.querySelector('#smtpCredentialCopy'),
  24. smtpUsername: document.querySelector('#smtpUsername'),
  25. smtpPassword: document.querySelector('#smtpPassword'),
  26. generateSmtpPassword: document.querySelector('#generateSmtpPassword'),
  27. dnsCredentialForm: document.querySelector('#dnsCredentialForm'),
  28. dnsCredentialList: document.querySelector('#dnsCredentialList'),
  29. dnsCredentialCount: document.querySelector('#dnsCredentialCount'),
  30. apiTokenForm: document.querySelector('#apiTokenForm'),
  31. apiTokenList: document.querySelector('#apiTokenList'),
  32. apiTokenCount: document.querySelector('#apiTokenCount'),
  33. defaultSenderHost: document.querySelector('#defaultSenderHost'),
  34. defaultSendingIp: document.querySelector('#defaultSendingIp'),
  35. defaultSpfExtra: document.querySelector('#defaultSpfExtra'),
  36. domainList: document.querySelector('#domainList'),
  37. domainCount: document.querySelector('#domainCount'),
  38. detailPanel: document.querySelector('#detailPanel'),
  39. adminPanel: document.querySelector('#adminPanel'),
  40. refreshButton: document.querySelector('#refreshButton'),
  41. logoutButton: document.querySelector('#logoutButton')
  42. };
  43. init();
  44. async function init() {
  45. bindEvents();
  46. await refreshAll();
  47. }
  48. function bindEvents() {
  49. els.refreshButton.addEventListener('click', refreshAll);
  50. els.logoutButton.addEventListener('click', logout);
  51. document.addEventListener('click', handleGlobalClick);
  52. els.addDomainForm.addEventListener('submit', addDomain);
  53. els.smtpCredentialForm.addEventListener('submit', saveSmtpCredential);
  54. els.generateSmtpPassword.addEventListener('click', generateSmtpPassword);
  55. els.dnsCredentialForm.addEventListener('submit', saveDnsCredential);
  56. els.apiTokenForm.addEventListener('submit', createApiToken);
  57. els.domainList.addEventListener('click', (event) => {
  58. const item = event.target.closest('[data-domain-id]');
  59. if (!item) return;
  60. state.selectedId = Number(item.dataset.domainId);
  61. render();
  62. });
  63. els.detailPanel.addEventListener('submit', handleDetailSubmit);
  64. els.adminPanel.addEventListener('submit', handleDetailSubmit);
  65. }
  66. async function refreshAll() {
  67. setBusy(true);
  68. try {
  69. const me = await api('/api/me');
  70. state.me = me.user;
  71. const baseCalls = [
  72. api('/api/config'),
  73. api('/api/domains'),
  74. api('/api/events'),
  75. api('/api/smtp-credential'),
  76. api('/api/dns-credentials'),
  77. api('/api/api-tokens')
  78. ];
  79. const [config, domains, events, smtpCredential, dnsCredentials, apiTokens] = await Promise.all(baseCalls);
  80. state.config = config;
  81. state.domains = domains.domains || [];
  82. state.events = events.events || [];
  83. state.smtpCredential = smtpCredential.credential || null;
  84. state.dnsCredentials = dnsCredentials.credentials || [];
  85. state.apiTokens = apiTokens.tokens || [];
  86. if (state.me?.role === 'admin') {
  87. const [settings, users] = await Promise.all([
  88. api('/api/admin/settings'),
  89. api('/api/admin/users')
  90. ]);
  91. state.settings = settings.settings || null;
  92. state.users = users.users || [];
  93. }
  94. if (!state.selectedId && state.domains.length) state.selectedId = state.domains[0].id;
  95. if (state.selectedId && !state.domains.some((domain) => domain.id === state.selectedId)) {
  96. state.selectedId = state.domains[0]?.id || null;
  97. }
  98. renderDefaults();
  99. render();
  100. } catch (error) {
  101. toast(error.message);
  102. } finally {
  103. setBusy(false);
  104. }
  105. }
  106. function renderDefaults() {
  107. if (!state.config) return;
  108. els.runtimeLine.textContent = `${state.config.mailHostname} / ${state.config.sendingIp || '未设置发信 IP'}`;
  109. els.defaultSenderHost.value = state.config.mailHostname || '';
  110. els.defaultSendingIp.value = state.config.sendingIp || '';
  111. els.defaultSpfExtra.value = state.config.defaultSpfMechanisms || '';
  112. els.smtpUsername.value = state.smtpCredential?.username || state.config.submission?.username || '';
  113. els.smtpCredentialState.textContent = state.smtpCredential?.passwordSet ? '已配置' : '未配置';
  114. els.smtpCredentialState.className = `badge ${state.smtpCredential?.passwordSet ? 'ok' : 'warn'}`;
  115. els.userRole.textContent = state.me?.role === 'admin' ? '管理员' : '用户';
  116. els.userRole.className = `badge ${state.me?.role === 'admin' ? 'ok' : 'idle'}`;
  117. els.accountBox.innerHTML = `
  118. <div class="live-row"><span>Username</span><code>${escapeHtml(state.me?.username || '')}</code></div>
  119. <div class="live-row"><span>Email</span><code>${escapeHtml(state.me?.email || '')}</code></div>
  120. `;
  121. renderDnsOptions();
  122. renderSmtpCredentialCopy();
  123. renderDnsCredentials();
  124. renderApiTokens();
  125. renderAdminPanel();
  126. els.securityNotice.classList.toggle('hidden', !state.config.usingDefaultAdminPassword);
  127. els.securityNotice.textContent = state.config.usingDefaultAdminPassword
  128. ? '当前仍在使用默认管理密码,请修改 .env 后重启服务。'
  129. : '';
  130. }
  131. function render() {
  132. els.domainCount.textContent = String(state.domains.length);
  133. renderDomainList();
  134. renderDetail();
  135. }
  136. function renderDnsOptions(selectedId = '') {
  137. const options = [
  138. '<option value="">不绑定,手动配置</option>',
  139. ...state.dnsCredentials.map((credential) => `
  140. <option value="${credential.id}" ${String(selectedId) === String(credential.id) ? 'selected' : ''}>
  141. ${escapeHtml(credential.name)} · ${providerLabel(credential.provider)}
  142. </option>
  143. `)
  144. ].join('');
  145. els.domainDnsCredential.innerHTML = options;
  146. }
  147. function renderSmtpCredentialCopy() {
  148. const credential = state.smtpCredential;
  149. if (!credential?.username && !credential?.passwordSet) {
  150. els.smtpCredentialCopy.innerHTML = '<p class="muted">保存后会在这里显示可复制的 SMTP 用户名和密码。</p>';
  151. return;
  152. }
  153. const password = credential?.password || '';
  154. els.smtpCredentialCopy.innerHTML = `
  155. <div class="copy-row">
  156. <span>Username</span>
  157. <code>${escapeHtml(credential.username || '')}</code>
  158. <button class="small-button" data-copy="${escapeAttr(credential.username || '')}" type="button">复制</button>
  159. </div>
  160. <div class="copy-row">
  161. <span>Password</span>
  162. ${password
  163. ? `<code>${escapeHtml(password)}</code><button class="small-button" data-copy="${escapeAttr(password)}" type="button">复制</button>`
  164. : '<p class="muted">旧密码无法回显,请重新设置一次新密码后复制。</p>'}
  165. </div>
  166. `;
  167. }
  168. function renderDnsCredentials() {
  169. els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
  170. if (!state.dnsCredentials.length) {
  171. els.dnsCredentialList.innerHTML = '<p class="muted">暂无 DNS API 凭据</p>';
  172. return;
  173. }
  174. els.dnsCredentialList.innerHTML = state.dnsCredentials.map((credential) => `
  175. <div class="mini-row">
  176. <div>
  177. <strong>${escapeHtml(credential.name)}</strong>
  178. <span class="muted">${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')}</span>
  179. </div>
  180. <div class="button-row">
  181. <button class="small-button" data-action="test-dns" data-id="${credential.id}" type="button">测试</button>
  182. <button class="danger-button small-danger" data-action="delete-dns" data-id="${credential.id}" type="button">删除</button>
  183. </div>
  184. </div>
  185. `).join('');
  186. }
  187. function renderApiTokens() {
  188. els.apiTokenCount.textContent = String(state.apiTokens.length);
  189. if (!state.apiTokens.length) {
  190. els.apiTokenList.innerHTML = '<p class="muted">暂无发送 Token</p>';
  191. return;
  192. }
  193. els.apiTokenList.innerHTML = state.apiTokens.map((token) => `
  194. <div class="mini-row">
  195. <div>
  196. <strong>${escapeHtml(token.name)}</strong>
  197. <span class="muted">${escapeHtml(token.tokenPrefix)}... · ${escapeHtml(formatDate(token.createdAt))}</span>
  198. </div>
  199. <button class="danger-button small-danger" data-action="delete-token" data-id="${token.id}" type="button">删除</button>
  200. </div>
  201. `).join('');
  202. }
  203. function renderAdminPanel() {
  204. if (state.me?.role !== 'admin') {
  205. els.adminPanel.classList.add('hidden');
  206. els.adminPanel.innerHTML = '';
  207. return;
  208. }
  209. const settings = state.settings || state.config || {};
  210. els.adminPanel.classList.remove('hidden');
  211. els.adminPanel.innerHTML = `
  212. <form class="compact-form" data-form="admin-settings">
  213. <div class="section-head">
  214. <h2>系统设置</h2>
  215. <span class="count">${state.users.length}</span>
  216. </div>
  217. <label>APP Base URL<input name="appBaseUrl" value="${escapeAttr(settings.appBaseUrl || '')}"></label>
  218. <label>发信主机<input name="mailHostname" value="${escapeAttr(settings.mailHostname || '')}"></label>
  219. <label>发信 IP<input name="sendingIp" value="${escapeAttr(settings.sendingIp || '')}"></label>
  220. <label>默认 SPF<textarea name="defaultSpfMechanisms" rows="2">${escapeHtml(settings.defaultSpfMechanisms || '')}</textarea></label>
  221. <div class="form-grid">
  222. <label>DMARC
  223. <select name="dmarcPolicy">
  224. ${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${settings.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}
  225. </select>
  226. </label>
  227. <label>验证后发信
  228. <select name="sendRequiresVerified">
  229. <option value="false" ${!settings.sendRequiresVerified ? 'selected' : ''}>否</option>
  230. <option value="true" ${settings.sendRequiresVerified ? 'selected' : ''}>是</option>
  231. </select>
  232. </label>
  233. </div>
  234. <label>DMARC rua<input name="dmarcRua" value="${escapeAttr(settings.dmarcRua || '')}"></label>
  235. <button class="primary-button" type="submit">保存系统设置</button>
  236. </form>
  237. <div class="mini-list user-mini-list">
  238. ${state.users.slice(0, 8).map((user) => `
  239. <div class="mini-row">
  240. <div>
  241. <strong>${escapeHtml(user.username)}</strong>
  242. <span class="muted">${escapeHtml(user.email)} · ${escapeHtml(user.role)} · ${escapeHtml(user.status)}</span>
  243. </div>
  244. <button class="${user.status === 'active' ? 'danger-button' : 'small-button'} small-danger" data-action="toggle-user" data-id="${user.id}" data-status="${user.status}" type="button">
  245. ${user.status === 'active' ? '禁用' : '启用'}
  246. </button>
  247. </div>
  248. `).join('')}
  249. </div>
  250. `;
  251. }
  252. function renderDomainList() {
  253. if (!state.domains.length) {
  254. els.domainList.innerHTML = '<p class="muted">暂无域名</p>';
  255. return;
  256. }
  257. els.domainList.innerHTML = state.domains.map((domain) => {
  258. const status = statusMeta(domain.status);
  259. return `
  260. <button class="domain-item ${domain.id === state.selectedId ? 'active' : ''}" data-domain-id="${domain.id}" type="button">
  261. <div class="item-line">
  262. <strong>${escapeHtml(domain.domain)}</strong>
  263. <span class="badge ${status.className}">${status.label}</span>
  264. </div>
  265. <span class="muted">${escapeHtml(domain.selector)}._domainkey</span>
  266. </button>
  267. `;
  268. }).join('');
  269. }
  270. function renderDetail() {
  271. const domain = state.domains.find((item) => item.id === state.selectedId);
  272. if (!domain) {
  273. els.detailPanel.innerHTML = `
  274. <div class="empty-state">
  275. <h2>选择或添加一个域名</h2>
  276. <p>DNS 引导、验证结果、DKIM 记录和测试发送会显示在这里。</p>
  277. </div>
  278. `;
  279. return;
  280. }
  281. const guide = domain.status || {};
  282. const records = guide.records || [];
  283. const warnings = guide.warnings || [];
  284. const checkedAt = guide.checkedAt ? formatDate(guide.checkedAt) : '尚未检查';
  285. els.detailPanel.innerHTML = `
  286. <div class="detail-header">
  287. <div class="detail-title">
  288. <h2>${escapeHtml(domain.domain)}</h2>
  289. <div class="status-strip">
  290. ${badge(statusMeta(guide))}
  291. <span class="badge idle">最近检查 ${escapeHtml(checkedAt)}</span>
  292. <span class="badge idle">Selector ${escapeHtml(domain.selector)}</span>
  293. </div>
  294. </div>
  295. <div class="detail-actions">
  296. <button class="primary-button" data-action="apply-dns" type="button">一键配置 DNS</button>
  297. <button class="secondary-button" data-action="check" type="button">立即检查</button>
  298. <button class="secondary-button" data-action="rotate-dkim" type="button">轮换 DKIM</button>
  299. <button class="danger-button" data-action="delete-domain" type="button">删除</button>
  300. </div>
  301. </div>
  302. <div class="detail-body">
  303. ${renderSetupOverview(domain, guide, records)}
  304. ${guide.apply ? renderApplyResult(guide.apply) : ''}
  305. ${warnings.length ? renderWarnings(warnings) : ''}
  306. <div class="grid-two">
  307. <section class="subpanel">
  308. <div class="subpanel-head">
  309. <h3>配置引导</h3>
  310. <button class="small-button" data-action="copy-all-dns" type="button">复制全部</button>
  311. </div>
  312. ${renderDnsGuide(records)}
  313. </section>
  314. <section class="subpanel">
  315. <h3>域名设置</h3>
  316. ${renderSettingsForm(domain)}
  317. </section>
  318. </div>
  319. <div class="grid-two">
  320. <section class="subpanel">
  321. <h3>当前 DNS</h3>
  322. ${renderLiveDns(guide.live)}
  323. </section>
  324. <section class="subpanel">
  325. <h3>测试发送</h3>
  326. ${renderSendForm(domain)}
  327. </section>
  328. </div>
  329. <div class="grid-two">
  330. <section class="subpanel api-box">
  331. <h3>发送 API</h3>
  332. <pre><code>${escapeHtml(apiExample(domain))}</code></pre>
  333. </section>
  334. <section class="subpanel">
  335. <h3>SMTP 发信</h3>
  336. ${renderSmtpBox(domain)}
  337. </section>
  338. </div>
  339. <section class="subpanel">
  340. <h3>最近发送</h3>
  341. ${renderEvents(domain)}
  342. </section>
  343. <section class="subpanel">
  344. <h3>增强安全记录</h3>
  345. ${renderOptionalRecords(guide.optionalRecords || [])}
  346. </section>
  347. </div>
  348. `;
  349. }
  350. function renderSetupOverview(domain, guide, records) {
  351. const important = [['verification', '域名验证'], ['dkim', 'DKIM'], ['spf', 'SPF'], ['dmarc', 'DMARC'], ['ptr', 'PTR']];
  352. const cards = important.map(([key, label]) => {
  353. const record = records.find((item) => item.key === key);
  354. const meta = statusMeta(record || {});
  355. return `<div class="metric-card"><span>${escapeHtml(label)}</span><strong>${record ? escapeHtml(meta.label) : '待生成'}</strong></div>`;
  356. }).join('');
  357. const credential = state.dnsCredentials.find((item) => item.id === domain.dnsCredentialId);
  358. return `
  359. <section class="guide-hero">
  360. <div>
  361. <span class="eyebrow">Sending domain</span>
  362. <h3>${escapeHtml(domain.domain)}</h3>
  363. <p>${escapeHtml(domain.senderHost)} / ${escapeHtml(domain.sendingIp)}</p>
  364. <p>${credential ? `DNS API: ${escapeHtml(credential.name)}` : 'DNS API: 未绑定'}</p>
  365. </div>
  366. <div class="summary-grid">${cards}</div>
  367. </section>
  368. `;
  369. }
  370. function renderApplyResult(apply) {
  371. const rows = apply.results || [];
  372. return `
  373. <section class="subpanel">
  374. <div class="subpanel-head">
  375. <h3>一键配置结果</h3>
  376. <span class="badge ${apply.ok ? 'ok' : 'warn'}">${apply.ok ? '完成' : '部分失败'}</span>
  377. </div>
  378. <div class="mini-list">
  379. ${rows.map((row) => `
  380. <div class="mini-row">
  381. <div>
  382. <strong>${escapeHtml(row.key)} · ${escapeHtml(row.type)}</strong>
  383. <span class="muted">${escapeHtml(row.host)} · ${escapeHtml(row.detail || row.error || '')}</span>
  384. </div>
  385. ${badge({ className: row.ok ? 'ok' : 'failed', label: row.ok ? '成功' : '失败' })}
  386. </div>
  387. `).join('')}
  388. </div>
  389. </section>
  390. `;
  391. }
  392. function renderDnsGuide(records) {
  393. if (!records.length) {
  394. return `<div class="empty-guide"><h4>尚未生成检查结果</h4><p>点击“立即检查”后会生成域名验证、DKIM、SPF、DMARC 和 PTR 引导。</p></div>`;
  395. }
  396. return `<div class="record-steps">${records.map((record, index) => renderRecordCard(record, index + 1)).join('')}</div>`;
  397. }
  398. function renderRecordCard(record, index) {
  399. const meta = statusMeta(record);
  400. const current = Array.isArray(record.current) ? record.current : (record.current ? [record.current] : []);
  401. const warnings = record.warnings || [];
  402. return `
  403. <article class="record-card ${meta.className}">
  404. <div class="record-step"><span>${index}</span></div>
  405. <div class="record-card-body">
  406. <div class="record-card-title">
  407. <div><h4>${escapeHtml(record.label)}</h4><p>${escapeHtml(record.type)} · ${escapeHtml(record.host)}</p></div>
  408. ${badge(meta)}
  409. </div>
  410. <div class="dns-value">
  411. <span>目标值</span>
  412. <code>${escapeHtml(record.value || '')}</code>
  413. <button class="small-button" data-copy="${escapeAttr(record.value || '')}" type="button">复制值</button>
  414. </div>
  415. ${current.length ? `<div class="dns-current"><span>当前值</span>${current.map((value) => `<code>${escapeHtml(value)}</code>`).join('')}</div>` : ''}
  416. ${warnings.length ? `<ul class="inline-warnings">${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul>` : ''}
  417. </div>
  418. </article>
  419. `;
  420. }
  421. function renderRecordTable(records) {
  422. if (!records.length) return '<p class="muted">点击“立即检查”生成 SPF、DKIM、DMARC 和 PTR 检查结果。</p>';
  423. return `
  424. <div class="record-table-wrap">
  425. <table>
  426. <thead><tr><th>项目</th><th>主机</th><th>类型</th><th>目标值</th><th>状态</th><th></th></tr></thead>
  427. <tbody>${records.map(renderRecordRow).join('')}</tbody>
  428. </table>
  429. </div>
  430. `;
  431. }
  432. function renderRecordRow(record) {
  433. const meta = statusMeta(record);
  434. return `
  435. <tr>
  436. <td>${escapeHtml(record.label)}</td>
  437. <td class="mono">${escapeHtml(record.host)}</td>
  438. <td>${escapeHtml(record.type)}</td>
  439. <td><code>${escapeHtml(record.value || '')}</code></td>
  440. <td>${badge(meta)}</td>
  441. <td><button class="small-button" data-copy="${escapeAttr(record.value || '')}" type="button">复制</button></td>
  442. </tr>
  443. `;
  444. }
  445. function renderSettingsForm(domain) {
  446. const dnsOptions = [
  447. '<option value="">不绑定</option>',
  448. ...state.dnsCredentials.map((credential) => `<option value="${credential.id}" ${domain.dnsCredentialId === credential.id ? 'selected' : ''}>${escapeHtml(credential.name)} · ${providerLabel(credential.provider)}</option>`)
  449. ].join('');
  450. return `
  451. <form class="compact-form" data-form="settings">
  452. <label>DNS API<select name="dnsCredentialId">${dnsOptions}</select></label>
  453. <div class="form-grid">
  454. <label>DKIM selector<input name="selector" value="${escapeAttr(domain.selector)}"></label>
  455. <label>DMARC 策略
  456. <select name="dmarcPolicy">${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${domain.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}</select>
  457. </label>
  458. </div>
  459. <label>发信主机<input name="senderHost" value="${escapeAttr(domain.senderHost)}"></label>
  460. <label>发信 IP<input name="sendingIp" value="${escapeAttr(domain.sendingIp)}"></label>
  461. <label>兼容第三方 SPF<textarea name="spfExtra" rows="3">${escapeHtml(domain.spfExtra || '')}</textarea></label>
  462. <label>DMARC rua<input name="dmarcRua" value="${escapeAttr(domain.dmarcRua || '')}" placeholder="mailto:dmarc@example.com"></label>
  463. <button class="secondary-button" type="submit">保存设置</button>
  464. </form>
  465. `;
  466. }
  467. function renderLiveDns(live) {
  468. if (!live) return '<p class="muted">暂无检查数据</p>';
  469. const rows = [['根域 TXT', live.rootTxt], ['验证 TXT', live.verificationTxt], ['DKIM TXT', live.dkimTxt], ['DMARC TXT', live.dmarcTxt], ['发信主机 A', live.senderA], ['发信 IP PTR', live.ptr]];
  470. return `<div class="live-list">${rows.map(([label, values]) => `<div class="live-row"><span>${label}</span>${(values && values.length) ? values.map((value) => `<code>${escapeHtml(value)}</code>`).join('') : '<p class="muted">未发现</p>'}</div>`).join('')}</div>`;
  471. }
  472. function renderSendForm(domain) {
  473. return `
  474. <form class="send-form" data-form="send">
  475. <label>From<input name="from" value="noreply@${escapeAttr(domain.domain)}"></label>
  476. <label>To<input name="to" placeholder="user@example.com" required></label>
  477. <label>Subject<input name="subject" value="MailHub test for ${escapeAttr(domain.domain)}"></label>
  478. <label>Text<textarea name="text" rows="5">This is a MailHub test message from ${escapeHtml(domain.domain)}.</textarea></label>
  479. <button class="primary-button" type="submit">发送测试</button>
  480. </form>
  481. `;
  482. }
  483. function renderEvents(domain) {
  484. const events = state.events.filter((event) => event.domain === domain.domain).slice(0, 8);
  485. if (!events.length) return '<p class="muted">暂无发送记录</p>';
  486. return `<div class="event-list">${events.map((event) => `<div class="event-row"><div class="item-line"><strong>${escapeHtml(event.subject)}</strong>${badge({ className: event.status === 'queued' ? 'ok' : 'failed', label: event.status })}</div><span class="muted">${escapeHtml(event.sender)} -> ${escapeHtml((event.recipients || []).join(', '))}</span><span class="muted">${escapeHtml(formatDate(event.createdAt))}</span></div>`).join('')}</div>`;
  487. }
  488. function renderSmtpBox(domain) {
  489. const submission = state.config?.submission;
  490. const credential = state.smtpCredential;
  491. const password = credential?.password || '';
  492. if (!submission?.enabled) return '<p class="muted">SMTP Submission 未启用。</p>';
  493. return `
  494. <div class="smtp-grid">
  495. <div class="smtp-row"><span>Host</span><code>${escapeHtml(submission.host)}</code></div>
  496. <div class="smtp-row"><span>Ports</span>${(submission.ports || []).map((item) => `<code>${escapeHtml(item.port)} · ${escapeHtml(item.protocol)}</code>`).join('')}</div>
  497. <div class="smtp-row"><span>Username</span><div class="copy-row inline"><code>${escapeHtml(credential?.username || submission.username || '')}</code><button class="small-button" data-copy="${escapeAttr(credential?.username || submission.username || '')}" type="button">复制</button></div></div>
  498. <div class="smtp-row"><span>Password</span>${password ? `<div class="copy-row inline"><code>${escapeHtml(password)}</code><button class="small-button" data-copy="${escapeAttr(password)}" type="button">复制</button></div>` : '<p class="muted">旧密码无法回显,请在左侧重新设置一次新密码后复制。</p>'}</div>
  499. <div class="smtp-row"><span>AUTH</span><code>${submission.requireTlsForAuth ? '需要 TLS 后认证' : '允许明文认证'}</code></div>
  500. <div class="smtp-row"><span>From</span><code>noreply@${escapeHtml(domain.domain)}</code></div>
  501. </div>
  502. `;
  503. }
  504. function renderOptionalRecords(records) {
  505. if (!records.length) return '<p class="muted">完成基础发信配置后可逐步启用。</p>';
  506. return renderRecordTable(records.map((record) => ({ ...record, status: 'idle' })));
  507. }
  508. function renderWarnings(warnings) {
  509. return `<ul class="warning-list">${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul>`;
  510. }
  511. function apiExample(domain) {
  512. const token = state.apiTokens[0] ? `${state.apiTokens[0].tokenPrefix}...` : '<USER_API_TOKEN>';
  513. return `curl -X POST ${state.config?.appBaseUrl || 'https://mail.ss5.xyz'}/api/send \\
  514. -H 'Authorization: Bearer ${token}' \\
  515. -H 'Content-Type: application/json' \\
  516. -d '{
  517. "from": "noreply@${domain.domain}",
  518. "to": "user@example.com",
  519. "subject": "Hello from MailHub",
  520. "text": "Signed with DKIM and queued by MailHub."
  521. }'`;
  522. }
  523. async function addDomain(event) {
  524. event.preventDefault();
  525. await mutate(async () => {
  526. const data = Object.fromEntries(new FormData(event.target).entries());
  527. const result = await api('/api/domains', { method: 'POST', body: JSON.stringify(data) });
  528. state.domains.unshift(result.domain);
  529. state.selectedId = result.domain.id;
  530. event.target.reset();
  531. renderDefaults();
  532. render();
  533. await checkSelected();
  534. });
  535. }
  536. async function saveSmtpCredential(event) {
  537. event.preventDefault();
  538. await mutate(async () => {
  539. const data = Object.fromEntries(new FormData(event.target).entries());
  540. const result = await api('/api/smtp-credential', { method: 'PUT', body: JSON.stringify(data) });
  541. state.smtpCredential = result.credential;
  542. if (state.config?.submission) {
  543. state.config.submission.username = result.credential.username;
  544. state.config.submission.passwordSet = result.credential.passwordSet;
  545. }
  546. els.smtpPassword.value = '';
  547. renderDefaults();
  548. render();
  549. toast('SMTP 凭据已保存');
  550. });
  551. }
  552. async function saveDnsCredential(event) {
  553. event.preventDefault();
  554. await mutate(async () => {
  555. const data = Object.fromEntries(new FormData(event.target).entries());
  556. const result = await api('/api/dns-credentials', { method: 'POST', body: JSON.stringify(data) });
  557. state.dnsCredentials.unshift(result.credential);
  558. event.target.reset();
  559. renderDefaults();
  560. render();
  561. toast('DNS 凭据已保存');
  562. });
  563. }
  564. async function createApiToken(event) {
  565. event.preventDefault();
  566. await mutate(async () => {
  567. const data = Object.fromEntries(new FormData(event.target).entries());
  568. const result = await api('/api/api-tokens', { method: 'POST', body: JSON.stringify(data) });
  569. state.apiTokens.unshift(result.token);
  570. event.target.reset();
  571. renderDefaults();
  572. render();
  573. await navigator.clipboard.writeText(result.token.token);
  574. toast('Token 已生成并复制,请立即保存');
  575. });
  576. }
  577. function generateSmtpPassword() {
  578. const bytes = new Uint8Array(24);
  579. crypto.getRandomValues(bytes);
  580. const password = btoa(String.fromCharCode(...bytes)).replace(/[+/=]/g, '').slice(0, 28);
  581. els.smtpPassword.type = 'text';
  582. els.smtpPassword.value = password;
  583. els.smtpPassword.focus();
  584. els.smtpPassword.select();
  585. }
  586. async function handleGlobalClick(event) {
  587. const copyButton = event.target.closest('[data-copy]');
  588. if (copyButton) {
  589. event.preventDefault();
  590. const value = copyButton.dataset.copy || '';
  591. if (!value) return;
  592. await navigator.clipboard.writeText(value);
  593. toast('已复制');
  594. return;
  595. }
  596. const action = event.target.closest('[data-action]')?.dataset.action;
  597. if (!action) return;
  598. const id = Number(event.target.closest('[data-id]')?.dataset.id || 0);
  599. if (action === 'check') return checkSelected();
  600. if (action === 'apply-dns') return applyDnsSelected();
  601. if (action === 'copy-all-dns') return copyAllDns();
  602. if (action === 'rotate-dkim') return rotateDkim();
  603. if (action === 'delete-domain') return deleteSelected();
  604. if (action === 'test-dns') return testDnsCredential(id);
  605. if (action === 'delete-dns') return deleteDnsCredential(id);
  606. if (action === 'delete-token') return deleteApiToken(id);
  607. if (action === 'toggle-user') return toggleUser(id, event.target.closest('[data-status]')?.dataset.status);
  608. }
  609. async function handleDetailSubmit(event) {
  610. const form = event.target.closest('form[data-form]');
  611. if (!form) return;
  612. event.preventDefault();
  613. if (form.dataset.form === 'settings') return saveSettings(form);
  614. if (form.dataset.form === 'send') return sendTest(form);
  615. if (form.dataset.form === 'admin-settings') return saveAdminSettings(form);
  616. }
  617. async function checkSelected() {
  618. const id = state.selectedId;
  619. if (!id) return;
  620. await mutate(async () => {
  621. const result = await api(`/api/domains/${id}/check`, { method: 'POST' });
  622. replaceDomain(result.domain);
  623. render();
  624. });
  625. }
  626. async function applyDnsSelected() {
  627. const id = state.selectedId;
  628. if (!id) return;
  629. await mutate(async () => {
  630. const result = await api(`/api/domains/${id}/apply-dns`, { method: 'POST' });
  631. replaceDomain(result.domain);
  632. render();
  633. toast(result.apply?.ok ? 'DNS 配置完成' : 'DNS 配置部分失败');
  634. });
  635. }
  636. async function saveSettings(form) {
  637. const id = state.selectedId;
  638. const data = Object.fromEntries(new FormData(form).entries());
  639. await mutate(async () => {
  640. const result = await api(`/api/domains/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
  641. replaceDomain(result.domain);
  642. render();
  643. await checkSelected();
  644. });
  645. }
  646. async function sendTest(form) {
  647. const id = state.selectedId;
  648. const data = Object.fromEntries(new FormData(form).entries());
  649. await mutate(async () => {
  650. await api(`/api/domains/${id}/test-send`, { method: 'POST', body: JSON.stringify(data) });
  651. const events = await api('/api/events');
  652. state.events = events.events || [];
  653. render();
  654. toast('已提交到发信队列');
  655. });
  656. }
  657. async function saveAdminSettings(form) {
  658. const data = Object.fromEntries(new FormData(form).entries());
  659. await mutate(async () => {
  660. const result = await api('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(data) });
  661. state.settings = result.settings;
  662. state.config = { ...state.config, ...result.settings };
  663. renderDefaults();
  664. render();
  665. toast('系统设置已保存');
  666. });
  667. }
  668. async function rotateDkim() {
  669. const id = state.selectedId;
  670. if (!confirm('轮换 DKIM 后需要更新 DNS TXT 记录。继续?')) return;
  671. await mutate(async () => {
  672. const result = await api(`/api/domains/${id}/rotate-dkim`, { method: 'POST' });
  673. replaceDomain(result.domain);
  674. render();
  675. await checkSelected();
  676. });
  677. }
  678. async function deleteSelected() {
  679. const id = state.selectedId;
  680. const domain = state.domains.find((item) => item.id === id);
  681. if (!confirm(`删除 ${domain?.domain || '该域名'}?`)) return;
  682. await mutate(async () => {
  683. await api(`/api/domains/${id}`, { method: 'DELETE' });
  684. state.domains = state.domains.filter((item) => item.id !== id);
  685. state.selectedId = state.domains[0]?.id || null;
  686. render();
  687. });
  688. }
  689. async function testDnsCredential(id) {
  690. await mutate(async () => {
  691. const result = await api(`/api/dns-credentials/${id}/test`, { method: 'POST' });
  692. toast(result.ok ? 'DNS API 连接成功' : result.error || 'DNS API 连接失败');
  693. });
  694. }
  695. async function deleteDnsCredential(id) {
  696. if (!confirm('删除该 DNS API 凭据?')) return;
  697. await mutate(async () => {
  698. await api(`/api/dns-credentials/${id}`, { method: 'DELETE' });
  699. state.dnsCredentials = state.dnsCredentials.filter((item) => item.id !== id);
  700. state.domains = state.domains.map((domain) => domain.dnsCredentialId === id ? { ...domain, dnsCredentialId: null } : domain);
  701. renderDefaults();
  702. render();
  703. });
  704. }
  705. async function deleteApiToken(id) {
  706. if (!confirm('删除该 API Token?')) return;
  707. await mutate(async () => {
  708. await api(`/api/api-tokens/${id}`, { method: 'DELETE' });
  709. state.apiTokens = state.apiTokens.filter((item) => item.id !== id);
  710. renderDefaults();
  711. render();
  712. });
  713. }
  714. async function toggleUser(id, status) {
  715. const next = status === 'active' ? 'disabled' : 'active';
  716. await mutate(async () => {
  717. const result = await api(`/api/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify({ status: next }) });
  718. state.users = state.users.map((user) => user.id === id ? result.user : user);
  719. renderDefaults();
  720. });
  721. }
  722. async function copyAllDns() {
  723. const domain = state.domains.find((item) => item.id === state.selectedId);
  724. const records = domain?.status?.records || [];
  725. if (!records.length) return toast('暂无 DNS 记录');
  726. const text = records.map((record) => `${record.host}\t${record.type}\t${record.value || ''}`).join('\n');
  727. await navigator.clipboard.writeText(text);
  728. toast('已复制全部 DNS 记录');
  729. }
  730. async function logout() {
  731. setBusy(true);
  732. try {
  733. await fetch('/api/logout', { method: 'POST' });
  734. } finally {
  735. window.location.href = '/login';
  736. }
  737. }
  738. async function mutate(fn) {
  739. setBusy(true);
  740. try {
  741. await fn();
  742. } catch (error) {
  743. toast(error.message);
  744. } finally {
  745. setBusy(false);
  746. }
  747. }
  748. async function api(path, options = {}) {
  749. const response = await fetch(path, {
  750. ...options,
  751. headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
  752. });
  753. const text = await response.text();
  754. const payload = text ? JSON.parse(text) : {};
  755. if (!response.ok) {
  756. if (response.status === 401) {
  757. window.location.href = '/login';
  758. return {};
  759. }
  760. throw new Error(payload.error || `HTTP ${response.status}`);
  761. }
  762. return payload;
  763. }
  764. function replaceDomain(domain) {
  765. state.domains = state.domains.map((item) => item.id === domain.id ? domain : item);
  766. }
  767. function statusMeta(target) {
  768. if (!target || (!target.checkedAt && !target.status)) return { className: 'idle', label: '未检查' };
  769. if (target.verified || target.status === 'ok') return { className: 'ok', label: '通过' };
  770. if (target.status === 'missing') return { className: 'missing', label: '缺失' };
  771. if (target.status === 'warn') return { className: 'warn', label: '需调整' };
  772. return { className: 'warn', label: '待配置' };
  773. }
  774. function badge(meta) {
  775. return `<span class="badge ${meta.className}">${escapeHtml(meta.label)}</span>`;
  776. }
  777. function providerLabel(provider) {
  778. return { cloudflare: 'Cloudflare', aliyun: '阿里云 DNS', dnspod: '腾讯云 DNSPod' }[provider] || provider;
  779. }
  780. function setBusy(value) {
  781. state.busy = value;
  782. document.body.classList.toggle('busy', value);
  783. els.refreshButton.disabled = value;
  784. }
  785. function toast(message) {
  786. const node = document.createElement('div');
  787. node.className = 'notice';
  788. node.textContent = message;
  789. node.style.position = 'fixed';
  790. node.style.right = '18px';
  791. node.style.bottom = '18px';
  792. node.style.zIndex = '10';
  793. document.body.appendChild(node);
  794. setTimeout(() => node.remove(), 2800);
  795. }
  796. function formatDate(value) {
  797. if (!value) return '';
  798. return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(value));
  799. }
  800. function escapeHtml(value) {
  801. return String(value ?? '')
  802. .replace(/&/g, '&amp;')
  803. .replace(/</g, '&lt;')
  804. .replace(/>/g, '&gt;')
  805. .replace(/"/g, '&quot;')
  806. .replace(/'/g, '&#039;');
  807. }
  808. function escapeAttr(value) {
  809. return escapeHtml(value).replace(/`/g, '&#096;');
  810. }