app.js 25 KB

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