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.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 = `
${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 ? `
${warnings.map((warning) => `- ${escapeHtml(warning)}
`).join('')}
` : ''}
`;
}
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 `${warnings.map((warning) => `- ${escapeHtml(warning)}
`).join('')}
`;
}
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, '`');
}