const state = { me: null, config: null, smtpCredential: null, dnsCredentials: [], apiTokens: [], domains: [], events: [], users: [], settings: null, selectedId: null, busy: false }; const els = { runtimeLine: document.querySelector('#runtimeLine'), securityNotice: document.querySelector('#securityNotice'), accountBox: document.querySelector('#accountBox'), userRole: document.querySelector('#userRole'), addDomainForm: document.querySelector('#addDomainForm'), domainDnsCredential: document.querySelector('#domainDnsCredential'), smtpCredentialForm: document.querySelector('#smtpCredentialForm'), smtpCredentialState: document.querySelector('#smtpCredentialState'), smtpCredentialCopy: document.querySelector('#smtpCredentialCopy'), smtpUsername: document.querySelector('#smtpUsername'), smtpPassword: document.querySelector('#smtpPassword'), generateSmtpPassword: document.querySelector('#generateSmtpPassword'), dnsCredentialForm: document.querySelector('#dnsCredentialForm'), dnsCredentialList: document.querySelector('#dnsCredentialList'), dnsCredentialCount: document.querySelector('#dnsCredentialCount'), apiTokenForm: document.querySelector('#apiTokenForm'), apiTokenList: document.querySelector('#apiTokenList'), apiTokenCount: document.querySelector('#apiTokenCount'), defaultSenderHost: document.querySelector('#defaultSenderHost'), defaultSendingIp: document.querySelector('#defaultSendingIp'), defaultSpfExtra: document.querySelector('#defaultSpfExtra'), domainList: document.querySelector('#domainList'), domainCount: document.querySelector('#domainCount'), detailPanel: document.querySelector('#detailPanel'), adminPanel: document.querySelector('#adminPanel'), 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', handleGlobalClick); els.addDomainForm.addEventListener('submit', addDomain); els.smtpCredentialForm.addEventListener('submit', saveSmtpCredential); els.generateSmtpPassword.addEventListener('click', generateSmtpPassword); els.dnsCredentialForm.addEventListener('submit', saveDnsCredential); els.apiTokenForm.addEventListener('submit', createApiToken); els.domainList.addEventListener('click', (event) => { const item = event.target.closest('[data-domain-id]'); if (!item) return; state.selectedId = Number(item.dataset.domainId); render(); }); els.detailPanel.addEventListener('submit', handleDetailSubmit); els.adminPanel.addEventListener('submit', handleDetailSubmit); } async function refreshAll() { setBusy(true); try { const me = await api('/api/me'); state.me = me.user; const baseCalls = [ api('/api/config'), api('/api/domains'), api('/api/events'), api('/api/smtp-credential'), api('/api/dns-credentials'), api('/api/api-tokens') ]; const [config, domains, events, smtpCredential, dnsCredentials, apiTokens] = await Promise.all(baseCalls); state.config = config; state.domains = domains.domains || []; state.events = events.events || []; state.smtpCredential = smtpCredential.credential || null; state.dnsCredentials = dnsCredentials.credentials || []; state.apiTokens = apiTokens.tokens || []; if (state.me?.role === 'admin') { const [settings, users] = await Promise.all([ api('/api/admin/settings'), api('/api/admin/users') ]); state.settings = settings.settings || null; state.users = users.users || []; } if (!state.selectedId && state.domains.length) state.selectedId = state.domains[0].id; if (state.selectedId && !state.domains.some((domain) => domain.id === state.selectedId)) { state.selectedId = state.domains[0]?.id || null; } 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'}`; els.userRole.textContent = state.me?.role === 'admin' ? '管理员' : '用户'; els.userRole.className = `badge ${state.me?.role === 'admin' ? 'ok' : 'idle'}`; els.accountBox.innerHTML = `
Username${escapeHtml(state.me?.username || '')}
Email${escapeHtml(state.me?.email || '')}
`; renderDnsOptions(); renderSmtpCredentialCopy(); renderDnsCredentials(); renderApiTokens(); renderAdminPanel(); els.securityNotice.classList.toggle('hidden', !state.config.usingDefaultAdminPassword); els.securityNotice.textContent = state.config.usingDefaultAdminPassword ? '当前仍在使用默认管理密码,请修改 .env 后重启服务。' : ''; } function render() { els.domainCount.textContent = String(state.domains.length); renderDomainList(); renderDetail(); } function renderDnsOptions(selectedId = '') { const options = [ '', ...state.dnsCredentials.map((credential) => ` `) ].join(''); els.domainDnsCredential.innerHTML = options; } function renderSmtpCredentialCopy() { 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 renderDnsCredentials() { els.dnsCredentialCount.textContent = String(state.dnsCredentials.length); if (!state.dnsCredentials.length) { els.dnsCredentialList.innerHTML = '

暂无 DNS API 凭据

'; return; } els.dnsCredentialList.innerHTML = state.dnsCredentials.map((credential) => `
${escapeHtml(credential.name)} ${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')}
`).join(''); } function renderApiTokens() { els.apiTokenCount.textContent = String(state.apiTokens.length); if (!state.apiTokens.length) { els.apiTokenList.innerHTML = '

暂无发送 Token

'; return; } els.apiTokenList.innerHTML = state.apiTokens.map((token) => `
${escapeHtml(token.name)} ${escapeHtml(token.tokenPrefix)}... · ${escapeHtml(formatDate(token.createdAt))}
`).join(''); } function renderAdminPanel() { if (state.me?.role !== 'admin') { els.adminPanel.classList.add('hidden'); els.adminPanel.innerHTML = ''; return; } const settings = state.settings || state.config || {}; els.adminPanel.classList.remove('hidden'); els.adminPanel.innerHTML = `

系统设置

${state.users.length}
${state.users.slice(0, 8).map((user) => `
${escapeHtml(user.username)} ${escapeHtml(user.email)} · ${escapeHtml(user.role)} · ${escapeHtml(user.status)}
`).join('')}
`; } 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)} ${guide.apply ? renderApplyResult(guide.apply) : ''} ${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(''); const credential = state.dnsCredentials.find((item) => item.id === domain.dnsCredentialId); return `
Sending domain

${escapeHtml(domain.domain)}

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

${credential ? `DNS API: ${escapeHtml(credential.name)}` : 'DNS API: 未绑定'}

${cards}
`; } function renderApplyResult(apply) { const rows = apply.results || []; return `

一键配置结果

${apply.ok ? '完成' : '部分失败'}
${rows.map((row) => `
${escapeHtml(row.key)} · ${escapeHtml(row.type)} ${escapeHtml(row.host)} · ${escapeHtml(row.detail || row.error || '')}
${badge({ className: row.ok ? 'ok' : 'failed', label: row.ok ? '成功' : '失败' })}
`).join('')}
`; } 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) { const dnsOptions = [ '', ...state.dnsCredentials.map((credential) => ``) ].join(''); 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 后认证' : '允许明文认证'}
Fromnoreply@${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) { const token = state.apiTokens[0] ? `${state.apiTokens[0].tokenPrefix}...` : ''; return `curl -X POST ${state.config?.appBaseUrl || 'https://mail.ss5.xyz'}/api/send \\ -H 'Authorization: Bearer ${token}' \\ -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 MailHub." }'`; } async function addDomain(event) { event.preventDefault(); await mutate(async () => { const data = Object.fromEntries(new FormData(event.target).entries()); 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(); }); } async function saveSmtpCredential(event) { event.preventDefault(); await mutate(async () => { const data = Object.fromEntries(new FormData(event.target).entries()); 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 凭据已保存'); }); } async function saveDnsCredential(event) { event.preventDefault(); await mutate(async () => { const data = Object.fromEntries(new FormData(event.target).entries()); const result = await api('/api/dns-credentials', { method: 'POST', body: JSON.stringify(data) }); state.dnsCredentials.unshift(result.credential); event.target.reset(); renderDefaults(); render(); toast('DNS 凭据已保存'); }); } async function createApiToken(event) { event.preventDefault(); await mutate(async () => { const data = Object.fromEntries(new FormData(event.target).entries()); const result = await api('/api/api-tokens', { method: 'POST', body: JSON.stringify(data) }); state.apiTokens.unshift(result.token); event.target.reset(); renderDefaults(); render(); await navigator.clipboard.writeText(result.token.token); toast('Token 已生成并复制,请立即保存'); }); } 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 handleGlobalClick(event) { const copyButton = event.target.closest('[data-copy]'); if (copyButton) { event.preventDefault(); const value = copyButton.dataset.copy || ''; if (!value) return; await navigator.clipboard.writeText(value); toast('已复制'); return; } const action = event.target.closest('[data-action]')?.dataset.action; if (!action) return; const id = Number(event.target.closest('[data-id]')?.dataset.id || 0); if (action === 'check') return checkSelected(); if (action === 'apply-dns') return applyDnsSelected(); if (action === 'copy-all-dns') return copyAllDns(); if (action === 'rotate-dkim') return rotateDkim(); if (action === 'delete-domain') return deleteSelected(); if (action === 'test-dns') return testDnsCredential(id); if (action === 'delete-dns') return deleteDnsCredential(id); if (action === 'delete-token') return deleteApiToken(id); if (action === 'toggle-user') return toggleUser(id, event.target.closest('[data-status]')?.dataset.status); } 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); if (form.dataset.form === 'admin-settings') return saveAdminSettings(form); } async function checkSelected() { const id = state.selectedId; if (!id) return; await mutate(async () => { const result = await api(`/api/domains/${id}/check`, { method: 'POST' }); replaceDomain(result.domain); render(); }); } async function applyDnsSelected() { const id = state.selectedId; if (!id) return; await mutate(async () => { const result = await api(`/api/domains/${id}/apply-dns`, { method: 'POST' }); replaceDomain(result.domain); render(); toast(result.apply?.ok ? 'DNS 配置完成' : 'DNS 配置部分失败'); }); } async function saveSettings(form) { const id = state.selectedId; const data = Object.fromEntries(new FormData(form).entries()); await mutate(async () => { const result = await api(`/api/domains/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); replaceDomain(result.domain); render(); await checkSelected(); }); } async function sendTest(form) { const id = state.selectedId; const data = Object.fromEntries(new FormData(form).entries()); await mutate(async () => { 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('已提交到发信队列'); }); } async function saveAdminSettings(form) { const data = Object.fromEntries(new FormData(form).entries()); await mutate(async () => { const result = await api('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(data) }); state.settings = result.settings; state.config = { ...state.config, ...result.settings }; renderDefaults(); render(); toast('系统设置已保存'); }); } async function rotateDkim() { const id = state.selectedId; if (!confirm('轮换 DKIM 后需要更新 DNS TXT 记录。继续?')) return; await mutate(async () => { const result = await api(`/api/domains/${id}/rotate-dkim`, { method: 'POST' }); replaceDomain(result.domain); render(); await checkSelected(); }); } async function deleteSelected() { const id = state.selectedId; const domain = state.domains.find((item) => item.id === id); if (!confirm(`删除 ${domain?.domain || '该域名'}?`)) return; await mutate(async () => { await api(`/api/domains/${id}`, { method: 'DELETE' }); state.domains = state.domains.filter((item) => item.id !== id); state.selectedId = state.domains[0]?.id || null; render(); }); } async function testDnsCredential(id) { await mutate(async () => { const result = await api(`/api/dns-credentials/${id}/test`, { method: 'POST' }); toast(result.ok ? 'DNS API 连接成功' : result.error || 'DNS API 连接失败'); }); } async function deleteDnsCredential(id) { if (!confirm('删除该 DNS API 凭据?')) return; await mutate(async () => { await api(`/api/dns-credentials/${id}`, { method: 'DELETE' }); state.dnsCredentials = state.dnsCredentials.filter((item) => item.id !== id); state.domains = state.domains.map((domain) => domain.dnsCredentialId === id ? { ...domain, dnsCredentialId: null } : domain); renderDefaults(); render(); }); } async function deleteApiToken(id) { if (!confirm('删除该 API Token?')) return; await mutate(async () => { await api(`/api/api-tokens/${id}`, { method: 'DELETE' }); state.apiTokens = state.apiTokens.filter((item) => item.id !== id); renderDefaults(); render(); }); } async function toggleUser(id, status) { const next = status === 'active' ? 'disabled' : 'active'; await mutate(async () => { const result = await api(`/api/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify({ status: next }) }); state.users = state.users.map((user) => user.id === id ? result.user : user); renderDefaults(); }); } 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 mutate(fn) { setBusy(true); try { await fn(); } catch (error) { toast(error.message); } finally { setBusy(false); } } 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 providerLabel(provider) { return { cloudflare: 'Cloudflare', aliyun: '阿里云 DNS', dnspod: '腾讯云 DNSPod' }[provider] || provider; } 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, '`'); }