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