const state = { config: null, smtpCredential: null, domains: [], events: [], selectedId: null, busy: false }; const els = { runtimeLine: document.querySelector('#runtimeLine'), securityNotice: document.querySelector('#securityNotice'), addDomainForm: document.querySelector('#addDomainForm'), smtpCredentialForm: document.querySelector('#smtpCredentialForm'), smtpCredentialState: document.querySelector('#smtpCredentialState'), smtpCredentialCopy: document.querySelector('#smtpCredentialCopy'), smtpUsername: document.querySelector('#smtpUsername'), smtpPassword: document.querySelector('#smtpPassword'), generateSmtpPassword: document.querySelector('#generateSmtpPassword'), defaultSenderHost: document.querySelector('#defaultSenderHost'), defaultSendingIp: document.querySelector('#defaultSendingIp'), defaultSpfExtra: document.querySelector('#defaultSpfExtra'), domainList: document.querySelector('#domainList'), domainCount: document.querySelector('#domainCount'), detailPanel: document.querySelector('#detailPanel'), refreshButton: document.querySelector('#refreshButton'), logoutButton: document.querySelector('#logoutButton') }; init(); async function init() { bindEvents(); await refreshAll(); } function bindEvents() { els.refreshButton.addEventListener('click', refreshAll); els.logoutButton.addEventListener('click', logout); document.addEventListener('click', handleCopyClick); els.addDomainForm.addEventListener('submit', addDomain); els.smtpCredentialForm.addEventListener('submit', saveSmtpCredential); els.generateSmtpPassword.addEventListener('click', generateSmtpPassword); els.domainList.addEventListener('click', async (event) => { const item = event.target.closest('[data-domain-id]'); if (!item) return; state.selectedId = Number(item.dataset.domainId); render(); }); els.detailPanel.addEventListener('click', handleDetailClick); els.detailPanel.addEventListener('submit', handleDetailSubmit); } async function refreshAll() { setBusy(true); try { const [config, domains, events, smtpCredential] = await Promise.all([ api('/api/config'), api('/api/domains'), api('/api/events'), api('/api/smtp-credential') ]); state.config = config; state.domains = domains.domains || []; state.events = events.events || []; state.smtpCredential = smtpCredential.credential || null; if (!state.selectedId && state.domains.length) state.selectedId = state.domains[0].id; renderDefaults(); render(); } catch (error) { toast(error.message); } finally { setBusy(false); } } function renderDefaults() { if (!state.config) return; els.runtimeLine.textContent = `${state.config.mailHostname} / ${state.config.sendingIp || '未设置发信 IP'}`; els.defaultSenderHost.value = state.config.mailHostname || ''; els.defaultSendingIp.value = state.config.sendingIp || ''; els.defaultSpfExtra.value = state.config.defaultSpfMechanisms || ''; els.smtpUsername.value = state.smtpCredential?.username || state.config.submission?.username || ''; els.smtpCredentialState.textContent = state.smtpCredential?.passwordSet ? '已配置' : '未配置'; els.smtpCredentialState.className = `badge ${state.smtpCredential?.passwordSet ? 'ok' : 'warn'}`; renderSmtpCredentialCopy(); els.securityNotice.classList.toggle('hidden', !state.config.usingDefaultAdminPassword); els.securityNotice.textContent = state.config.usingDefaultAdminPassword ? '当前仍在使用默认管理密码,请修改 .env 后重启服务。' : ''; } function renderSmtpCredentialCopy() { if (!els.smtpCredentialCopy) return; const credential = state.smtpCredential; if (!credential?.username && !credential?.passwordSet) { els.smtpCredentialCopy.innerHTML = '

保存后会在这里显示可复制的 SMTP 用户名和密码。

'; return; } const password = credential?.password || ''; els.smtpCredentialCopy.innerHTML = `
Username ${escapeHtml(credential.username || '')}
Password ${password ? `${escapeHtml(password)}` : '

旧密码无法回显,请重新设置一次新密码后复制。

'}
`; } function render() { els.domainCount.textContent = String(state.domains.length); renderDomainList(); renderDetail(); } function renderDomainList() { if (!state.domains.length) { els.domainList.innerHTML = '

暂无域名

'; return; } els.domainList.innerHTML = state.domains.map((domain) => { const status = statusMeta(domain.status); return ` `; }).join(''); } function renderDetail() { const domain = state.domains.find((item) => item.id === state.selectedId); if (!domain) { els.detailPanel.innerHTML = `

选择或添加一个域名

DNS 引导、验证结果、DKIM 记录和测试发送会显示在这里。

`; return; } const guide = domain.status || {}; const records = guide.records || []; const warnings = guide.warnings || []; const checkedAt = guide.checkedAt ? formatDate(guide.checkedAt) : '尚未检查'; els.detailPanel.innerHTML = `

${escapeHtml(domain.domain)}

${badge(statusMeta(guide))} 最近检查 ${escapeHtml(checkedAt)} Selector ${escapeHtml(domain.selector)}
${renderSetupOverview(domain, guide, records)} ${warnings.length ? renderWarnings(warnings) : ''}

配置引导

${renderDnsGuide(records)}

域名设置

${renderSettingsForm(domain)}

当前 DNS

${renderLiveDns(guide.live)}

测试发送

${renderSendForm(domain)}

发送 API

${escapeHtml(apiExample(domain))}

SMTP 发信

${renderSmtpBox(domain)}

最近发送

${renderEvents(domain)}

增强安全记录

${renderOptionalRecords(guide.optionalRecords || [])}
`; } function renderSetupOverview(domain, guide, records) { const important = [ ['verification', '域名验证'], ['dkim', 'DKIM'], ['spf', 'SPF'], ['dmarc', 'DMARC'], ['ptr', 'PTR'] ]; const cards = important.map(([key, label]) => { const record = records.find((item) => item.key === key); const meta = statusMeta(record || {}); return `
${escapeHtml(label)} ${record ? escapeHtml(meta.label) : '待生成'}
`; }).join(''); return `
Sending domain

${escapeHtml(domain.domain)}

${escapeHtml(domain.senderHost)} / ${escapeHtml(domain.sendingIp)}

${cards}
`; } function renderDnsGuide(records) { if (!records.length) { return `

尚未生成检查结果

点击“立即检查”后会生成域名验证、DKIM、SPF、DMARC 和 PTR 引导。

`; } return `
${records.map((record, index) => renderRecordCard(record, index + 1)).join('')}
`; } function renderRecordCard(record, index) { const meta = statusMeta(record); const current = Array.isArray(record.current) ? record.current : (record.current ? [record.current] : []); const warnings = record.warnings || []; return `
${index}

${escapeHtml(record.label)}

${escapeHtml(record.type)} · ${escapeHtml(record.host)}

${badge(meta)}
目标值 ${escapeHtml(record.value || '')}
${current.length ? `
当前值 ${current.map((value) => `${escapeHtml(value)}`).join('')}
` : ''} ${warnings.length ? ` ` : ''}
`; } function renderRecordTable(records) { if (!records.length) { return '

点击“立即检查”生成 SPF、DKIM、DMARC 和 PTR 检查结果。

'; } return `
${records.map(renderRecordRow).join('')}
项目 主机 类型 目标值 状态
`; } function renderRecordRow(record) { const meta = statusMeta(record); return ` ${escapeHtml(record.label)} ${escapeHtml(record.host)} ${escapeHtml(record.type)} ${escapeHtml(record.value || '')} ${badge(meta)} `; } function renderSettingsForm(domain) { return `
`; } function renderLiveDns(live) { if (!live) return '

暂无检查数据

'; const rows = [ ['根域 TXT', live.rootTxt], ['验证 TXT', live.verificationTxt], ['DKIM TXT', live.dkimTxt], ['DMARC TXT', live.dmarcTxt], ['发信主机 A', live.senderA], ['发信 IP PTR', live.ptr] ]; return `
${rows.map(([label, values]) => `
${label} ${(values && values.length) ? values.map((value) => `${escapeHtml(value)}`).join('') : '

未发现

'}
`).join('')}
`; } function renderSendForm(domain) { return `
`; } function renderEvents(domain) { const events = state.events.filter((event) => event.domain === domain.domain).slice(0, 8); if (!events.length) return '

暂无发送记录

'; return `
${events.map((event) => `
${escapeHtml(event.subject)} ${badge({ className: event.status === 'queued' ? 'ok' : 'failed', label: event.status })}
${escapeHtml(event.sender)} -> ${escapeHtml((event.recipients || []).join(', '))} ${escapeHtml(formatDate(event.createdAt))}
`).join('')}
`; } function renderSmtpBox(domain) { const submission = state.config?.submission; const credential = state.smtpCredential; const password = credential?.password || ''; if (!submission?.enabled) { return '

SMTP Submission 未启用。

'; } return `
Host ${escapeHtml(submission.host)}
Ports ${(submission.ports || []).map((item) => `${escapeHtml(item.port)} · ${escapeHtml(item.protocol)}`).join('')}
Username
${escapeHtml(credential?.username || submission.username || '')}
Password ${password ? `
${escapeHtml(password)}
` : '

旧密码无法回显,请在左侧重新设置一次新密码后复制。

'}
AUTH ${submission.requireTlsForAuth ? '需要 TLS 后认证' : '允许明文认证'}
From noreply@${escapeHtml(domain.domain)}
`; } function renderOptionalRecords(records) { if (!records.length) return '

完成基础发信配置后可逐步启用。

'; return renderRecordTable(records.map((record) => ({ ...record, status: 'idle' }))); } function renderWarnings(warnings) { return ` `; } function apiExample(domain) { return `curl -X POST ${state.config?.appBaseUrl || 'https://mail.ss5.xyz'}/api/send \\ -H 'Authorization: Bearer ' \\ -H 'Content-Type: application/json' \\ -d '{ "from": "noreply@${domain.domain}", "to": "user@example.com", "subject": "Hello from MailHub", "text": "Signed with DKIM and queued by Postfix." }'`; } async function addDomain(event) { event.preventDefault(); const data = Object.fromEntries(new FormData(event.target).entries()); setBusy(true); try { const result = await api('/api/domains', { method: 'POST', body: JSON.stringify(data) }); state.domains.unshift(result.domain); state.selectedId = result.domain.id; event.target.reset(); renderDefaults(); render(); await checkSelected(); } catch (error) { toast(error.message); } finally { setBusy(false); } } async function saveSmtpCredential(event) { event.preventDefault(); const data = Object.fromEntries(new FormData(event.target).entries()); setBusy(true); try { const result = await api('/api/smtp-credential', { method: 'PUT', body: JSON.stringify(data) }); state.smtpCredential = result.credential; if (state.config?.submission) { state.config.submission.username = result.credential.username; state.config.submission.passwordSet = result.credential.passwordSet; } els.smtpPassword.value = ''; renderDefaults(); render(); toast('SMTP 凭据已保存'); } catch (error) { toast(error.message); } finally { setBusy(false); } } function generateSmtpPassword() { const bytes = new Uint8Array(24); crypto.getRandomValues(bytes); const password = btoa(String.fromCharCode(...bytes)) .replace(/[+/=]/g, '') .slice(0, 28); els.smtpPassword.type = 'text'; els.smtpPassword.value = password; els.smtpPassword.focus(); els.smtpPassword.select(); } async function handleDetailClick(event) { const action = event.target.closest('[data-action]')?.dataset.action; if (!action) return; if (action === 'check') return checkSelected(); if (action === 'copy-all-dns') return copyAllDns(); if (action === 'rotate-dkim') return rotateDkim(); if (action === 'delete-domain') return deleteSelected(); } async function handleCopyClick(event) { const button = event.target.closest('[data-copy]'); if (!button) return; event.preventDefault(); const value = button.dataset.copy || ''; if (!value) return; await navigator.clipboard.writeText(value); toast('已复制'); } async function handleDetailSubmit(event) { const form = event.target.closest('form[data-form]'); if (!form) return; event.preventDefault(); if (form.dataset.form === 'settings') return saveSettings(form); if (form.dataset.form === 'send') return sendTest(form); } async function checkSelected() { const id = state.selectedId; if (!id) return; setBusy(true); try { const result = await api(`/api/domains/${id}/check`, { method: 'POST' }); replaceDomain(result.domain); render(); } catch (error) { toast(error.message); } finally { setBusy(false); } } async function saveSettings(form) { const id = state.selectedId; const data = Object.fromEntries(new FormData(form).entries()); setBusy(true); try { const result = await api(`/api/domains/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); replaceDomain(result.domain); render(); await checkSelected(); } catch (error) { toast(error.message); } finally { setBusy(false); } } async function sendTest(form) { const id = state.selectedId; const data = Object.fromEntries(new FormData(form).entries()); setBusy(true); try { await api(`/api/domains/${id}/test-send`, { method: 'POST', body: JSON.stringify(data) }); const events = await api('/api/events'); state.events = events.events || []; render(); toast('已提交到 Postfix 队列'); } catch (error) { toast(error.message); } finally { setBusy(false); } } async function rotateDkim() { const id = state.selectedId; if (!confirm('轮换 DKIM 后需要更新 DNS TXT 记录。继续?')) return; setBusy(true); try { const result = await api(`/api/domains/${id}/rotate-dkim`, { method: 'POST' }); replaceDomain(result.domain); render(); await checkSelected(); } catch (error) { toast(error.message); } finally { setBusy(false); } } async function deleteSelected() { const id = state.selectedId; const domain = state.domains.find((item) => item.id === id); if (!confirm(`删除 ${domain?.domain || '该域名'}?`)) return; setBusy(true); try { await api(`/api/domains/${id}`, { method: 'DELETE' }); state.domains = state.domains.filter((item) => item.id !== id); state.selectedId = state.domains[0]?.id || null; render(); } catch (error) { toast(error.message); } finally { setBusy(false); } } async function copyAllDns() { const domain = state.domains.find((item) => item.id === state.selectedId); const records = domain?.status?.records || []; if (!records.length) return toast('暂无 DNS 记录'); const text = records .map((record) => `${record.host}\t${record.type}\t${record.value || ''}`) .join('\n'); await navigator.clipboard.writeText(text); toast('已复制全部 DNS 记录'); } async function logout() { setBusy(true); try { await fetch('/api/logout', { method: 'POST' }); } finally { window.location.href = '/login'; } } async function api(path, options = {}) { const response = await fetch(path, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } }); const text = await response.text(); const payload = text ? JSON.parse(text) : {}; if (!response.ok) { if (response.status === 401) { window.location.href = '/login'; return {}; } throw new Error(payload.error || `HTTP ${response.status}`); } return payload; } function replaceDomain(domain) { state.domains = state.domains.map((item) => item.id === domain.id ? domain : item); } function statusMeta(target) { if (!target || (!target.checkedAt && !target.status)) return { className: 'idle', label: '未检查' }; if (target.verified || target.status === 'ok') return { className: 'ok', label: '通过' }; if (target.status === 'missing') return { className: 'missing', label: '缺失' }; if (target.status === 'warn') return { className: 'warn', label: '需调整' }; return { className: 'warn', label: '待配置' }; } function badge(meta) { return `${escapeHtml(meta.label)}`; } function setBusy(value) { state.busy = value; document.body.classList.toggle('busy', value); els.refreshButton.disabled = value; } function toast(message) { const node = document.createElement('div'); node.className = 'notice'; node.textContent = message; node.style.position = 'fixed'; node.style.right = '18px'; node.style.bottom = '18px'; node.style.zIndex = '10'; document.body.appendChild(node); setTimeout(() => node.remove(), 2800); } function formatDate(value) { if (!value) return ''; return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(value)); } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function escapeAttr(value) { return escapeHtml(value).replace(/`/g, '`'); }