|
|
@@ -9,16 +9,51 @@ const state = {
|
|
|
users: [],
|
|
|
settings: null,
|
|
|
selectedId: null,
|
|
|
+ view: 'domains',
|
|
|
busy: false
|
|
|
};
|
|
|
|
|
|
+const viewMeta = {
|
|
|
+ domains: {
|
|
|
+ eyebrow: 'Domains',
|
|
|
+ title: '发信域名',
|
|
|
+ subtitle: '管理发信域名、DNS 检查和测试发送。'
|
|
|
+ },
|
|
|
+ dns: {
|
|
|
+ eyebrow: 'DNS API',
|
|
|
+ title: 'DNS API 凭据',
|
|
|
+ subtitle: '按服务商保存多组 DNS 凭据,再绑定到具体域名。'
|
|
|
+ },
|
|
|
+ smtp: {
|
|
|
+ eyebrow: 'SMTP',
|
|
|
+ title: 'SMTP 凭据',
|
|
|
+ subtitle: '生成可复制的 SMTP 用户名和密码。'
|
|
|
+ },
|
|
|
+ tokens: {
|
|
|
+ eyebrow: 'API Tokens',
|
|
|
+ title: '发送 API Token',
|
|
|
+ subtitle: '为不同系统创建独立发信 Token。'
|
|
|
+ },
|
|
|
+ admin: {
|
|
|
+ eyebrow: 'Admin',
|
|
|
+ title: '系统设置',
|
|
|
+ subtitle: '管理员设置默认发信参数和用户状态。'
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
const els = {
|
|
|
runtimeLine: document.querySelector('#runtimeLine'),
|
|
|
+ viewEyebrow: document.querySelector('#viewEyebrow'),
|
|
|
+ viewTitle: document.querySelector('#viewTitle'),
|
|
|
securityNotice: document.querySelector('#securityNotice'),
|
|
|
accountBox: document.querySelector('#accountBox'),
|
|
|
userRole: document.querySelector('#userRole'),
|
|
|
+ navButtons: document.querySelectorAll('.primary-nav [data-view]'),
|
|
|
+ viewPanels: document.querySelectorAll('[data-view-panel]'),
|
|
|
+ adminNavButton: document.querySelector('#adminNavButton'),
|
|
|
addDomainForm: document.querySelector('#addDomainForm'),
|
|
|
domainDnsCredential: document.querySelector('#domainDnsCredential'),
|
|
|
+ domainReadiness: document.querySelector('#domainReadiness'),
|
|
|
smtpCredentialForm: document.querySelector('#smtpCredentialForm'),
|
|
|
smtpCredentialState: document.querySelector('#smtpCredentialState'),
|
|
|
smtpCredentialCopy: document.querySelector('#smtpCredentialCopy'),
|
|
|
@@ -26,8 +61,16 @@ const els = {
|
|
|
smtpPassword: document.querySelector('#smtpPassword'),
|
|
|
generateSmtpPassword: document.querySelector('#generateSmtpPassword'),
|
|
|
dnsCredentialForm: document.querySelector('#dnsCredentialForm'),
|
|
|
+ dnsCredentialFormTitle: document.querySelector('#dnsCredentialFormTitle'),
|
|
|
+ dnsCredentialFormHint: document.querySelector('#dnsCredentialFormHint'),
|
|
|
+ dnsCredentialIdField: document.querySelector('#dnsCredentialIdField'),
|
|
|
+ dnsCredentialName: document.querySelector('#dnsCredentialName'),
|
|
|
+ dnsDefaultTtl: document.querySelector('#dnsDefaultTtl'),
|
|
|
+ dnsProvider: document.querySelector('#dnsProvider'),
|
|
|
+ dnsZoneName: document.querySelector('#dnsZoneName'),
|
|
|
dnsCredentialList: document.querySelector('#dnsCredentialList'),
|
|
|
dnsCredentialCount: document.querySelector('#dnsCredentialCount'),
|
|
|
+ resetDnsCredentialForm: document.querySelector('#resetDnsCredentialForm'),
|
|
|
apiTokenForm: document.querySelector('#apiTokenForm'),
|
|
|
apiTokenList: document.querySelector('#apiTokenList'),
|
|
|
apiTokenCount: document.querySelector('#apiTokenCount'),
|
|
|
@@ -39,7 +82,9 @@ const els = {
|
|
|
detailPanel: document.querySelector('#detailPanel'),
|
|
|
adminPanel: document.querySelector('#adminPanel'),
|
|
|
refreshButton: document.querySelector('#refreshButton'),
|
|
|
- logoutButton: document.querySelector('#logoutButton')
|
|
|
+ refreshButtonMobile: document.querySelector('#refreshButtonMobile'),
|
|
|
+ logoutButton: document.querySelector('#logoutButton'),
|
|
|
+ toastHost: document.querySelector('#toastHost')
|
|
|
};
|
|
|
|
|
|
init();
|
|
|
@@ -51,17 +96,21 @@ async function init() {
|
|
|
|
|
|
function bindEvents() {
|
|
|
els.refreshButton.addEventListener('click', refreshAll);
|
|
|
+ els.refreshButtonMobile?.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.dnsProvider.addEventListener('change', toggleDnsProviderFields);
|
|
|
+ els.resetDnsCredentialForm.addEventListener('click', resetDnsCredentialForm);
|
|
|
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);
|
|
|
+ setView('domains');
|
|
|
render();
|
|
|
});
|
|
|
els.detailPanel.addEventListener('submit', handleDetailSubmit);
|
|
|
@@ -103,7 +152,7 @@ async function refreshAll() {
|
|
|
renderDefaults();
|
|
|
render();
|
|
|
} catch (error) {
|
|
|
- toast(error.message);
|
|
|
+ toast(error.message, 'error');
|
|
|
} finally {
|
|
|
setBusy(false);
|
|
|
}
|
|
|
@@ -111,42 +160,127 @@ async function refreshAll() {
|
|
|
|
|
|
function renderDefaults() {
|
|
|
if (!state.config) return;
|
|
|
- els.runtimeLine.textContent = `${state.config.mailHostname} / ${state.config.sendingIp || '未设置发信 IP'}`;
|
|
|
+ const ip = state.config.sendingIp || '未设置发信 IP';
|
|
|
+ els.runtimeLine.textContent = `${state.config.mailHostname} · ${ip} · ${viewMeta[state.view]?.subtitle || ''}`;
|
|
|
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.userRole.className = `badge ${state.me?.role === 'admin' ? 'bg-green-lt' : 'bg-secondary-lt'}`;
|
|
|
+ els.adminNavButton.classList.toggle('hidden', state.me?.role !== 'admin');
|
|
|
+ if (state.me?.role !== 'admin' && state.view === 'admin') setView('domains');
|
|
|
els.accountBox.innerHTML = `
|
|
|
- <div class="live-row"><span>Username</span><code>${escapeHtml(state.me?.username || '')}</code></div>
|
|
|
- <div class="live-row"><span>Email</span><code>${escapeHtml(state.me?.email || '')}</code></div>
|
|
|
+ <div class="list-group-item px-0 py-2 bg-transparent border-0">
|
|
|
+ <div class="text-secondary small">Username</div>
|
|
|
+ <code>${escapeHtml(state.me?.username || '')}</code>
|
|
|
+ </div>
|
|
|
+ <div class="list-group-item px-0 py-2 bg-transparent border-0">
|
|
|
+ <div class="text-secondary small">Email</div>
|
|
|
+ <code>${escapeHtml(state.me?.email || '')}</code>
|
|
|
+ </div>
|
|
|
`;
|
|
|
renderDnsOptions();
|
|
|
+ renderDomainReadiness();
|
|
|
renderSmtpCredentialCopy();
|
|
|
renderDnsCredentials();
|
|
|
renderApiTokens();
|
|
|
renderAdminPanel();
|
|
|
- els.securityNotice.classList.toggle('hidden', !state.config.usingDefaultAdminPassword);
|
|
|
- els.securityNotice.textContent = state.config.usingDefaultAdminPassword
|
|
|
- ? '当前仍在使用默认管理密码,请修改 .env 后重启服务。'
|
|
|
- : '';
|
|
|
+ renderSecurityNotice();
|
|
|
+ toggleDnsProviderFields();
|
|
|
}
|
|
|
|
|
|
function render() {
|
|
|
els.domainCount.textContent = String(state.domains.length);
|
|
|
+ els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
|
|
|
+ els.apiTokenCount.textContent = String(state.apiTokens.length);
|
|
|
+ renderViewMeta();
|
|
|
renderDomainList();
|
|
|
renderDetail();
|
|
|
}
|
|
|
|
|
|
+function renderViewMeta() {
|
|
|
+ const meta = viewMeta[state.view] || viewMeta.domains;
|
|
|
+ els.viewEyebrow.textContent = meta.eyebrow;
|
|
|
+ els.viewTitle.textContent = meta.title;
|
|
|
+ if (state.config) {
|
|
|
+ const ip = state.config.sendingIp || '未设置发信 IP';
|
|
|
+ els.runtimeLine.textContent = `${state.config.mailHostname} · ${ip} · ${meta.subtitle}`;
|
|
|
+ }
|
|
|
+ for (const button of els.navButtons) {
|
|
|
+ button.classList.toggle('active', button.dataset.view === state.view);
|
|
|
+ }
|
|
|
+ for (const panel of els.viewPanels) {
|
|
|
+ panel.classList.toggle('active', panel.dataset.viewPanel === state.view);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function setView(view) {
|
|
|
+ if (!viewMeta[view]) return;
|
|
|
+ if (view === 'admin' && state.me?.role !== 'admin') return;
|
|
|
+ state.view = view;
|
|
|
+ renderViewMeta();
|
|
|
+}
|
|
|
+
|
|
|
+function renderSecurityNotice() {
|
|
|
+ els.securityNotice.classList.toggle('hidden', !state.config.usingDefaultAdminPassword);
|
|
|
+ els.securityNotice.textContent = state.config.usingDefaultAdminPassword
|
|
|
+ ? '当前仍在使用默认管理密码,请修改 .env 后重启服务。'
|
|
|
+ : '';
|
|
|
+}
|
|
|
+
|
|
|
+function renderDomainReadiness() {
|
|
|
+ const verified = state.domains.filter((domain) => domain.status?.verified).length;
|
|
|
+ const latestEvent = state.events[0];
|
|
|
+ els.domainReadiness.innerHTML = `
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">运行概览</h3>
|
|
|
+ <p class="card-subtitle">关键配置集中在这里,缺哪一项就先补哪一项。</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="card-body">
|
|
|
+ <div class="row row-cards">
|
|
|
+ ${metricCard('域名', state.domains.length, `${verified} 个已通过`)}
|
|
|
+ ${metricCard('DNS API', state.dnsCredentials.length, state.dnsCredentials.length ? '可一键写入' : '尚未配置')}
|
|
|
+ ${metricCard('SMTP', state.smtpCredential?.passwordSet ? 'Ready' : 'Missing', state.smtpCredential?.username || '未设置凭据')}
|
|
|
+ ${metricCard('最近发送', latestEvent ? formatDate(latestEvent.createdAt) : '暂无', latestEvent?.status || '无记录')}
|
|
|
+ </div>
|
|
|
+ <div class="d-flex flex-wrap gap-2 mt-3">
|
|
|
+ ${!state.dnsCredentials.length ? '<button class="btn btn-outline-secondary" data-view="dns" type="button">新增 DNS API</button>' : ''}
|
|
|
+ ${!state.smtpCredential?.passwordSet ? '<button class="btn btn-outline-secondary" data-view="smtp" type="button">设置 SMTP</button>' : ''}
|
|
|
+ <button class="btn btn-outline-secondary" data-action="check" type="button" ${state.selectedId ? '' : 'disabled'}>检查当前域名</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+}
|
|
|
+
|
|
|
+function metricCard(label, value, hint) {
|
|
|
+ return `
|
|
|
+ <div class="col-sm-6">
|
|
|
+ <div class="card card-sm metric-card">
|
|
|
+ <div class="card-body">
|
|
|
+ <div class="text-secondary small">${escapeHtml(label)}</div>
|
|
|
+ <div class="h2 m-0">${escapeHtml(value)}</div>
|
|
|
+ <div class="text-secondary small">${escapeHtml(hint || '')}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+}
|
|
|
+
|
|
|
function renderDnsOptions(selectedId = '') {
|
|
|
+ const activeId = selectedId || els.domainDnsCredential.value || '';
|
|
|
+ if (!state.dnsCredentials.length) {
|
|
|
+ els.domainDnsCredential.innerHTML = '<option value="">未绑定,先手动配置或新增 DNS API</option>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
const options = [
|
|
|
'<option value="">不绑定,手动配置</option>',
|
|
|
...state.dnsCredentials.map((credential) => `
|
|
|
- <option value="${credential.id}" ${String(selectedId) === String(credential.id) ? 'selected' : ''}>
|
|
|
- ${escapeHtml(credential.name)} · ${providerLabel(credential.provider)}
|
|
|
+ <option value="${credential.id}" ${String(activeId) === String(credential.id) ? 'selected' : ''}>
|
|
|
+ ${escapeHtml(credential.name)} · ${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')}
|
|
|
</option>
|
|
|
`)
|
|
|
].join('');
|
|
|
@@ -156,7 +290,12 @@ function renderDnsOptions(selectedId = '') {
|
|
|
function renderSmtpCredentialCopy() {
|
|
|
const credential = state.smtpCredential;
|
|
|
if (!credential?.username && !credential?.passwordSet) {
|
|
|
- els.smtpCredentialCopy.innerHTML = '<p class="muted">保存后会在这里显示可复制的 SMTP 用户名和密码。</p>';
|
|
|
+ els.smtpCredentialCopy.innerHTML = `
|
|
|
+ <div class="empty">
|
|
|
+ <div class="empty-title">还没有 SMTP 凭据</div>
|
|
|
+ <p>保存用户名和密码后,这里会显示可复制内容。</p>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
return;
|
|
|
}
|
|
|
const password = credential?.password || '';
|
|
|
@@ -164,13 +303,13 @@ function renderSmtpCredentialCopy() {
|
|
|
<div class="copy-row">
|
|
|
<span>Username</span>
|
|
|
<code>${escapeHtml(credential.username || '')}</code>
|
|
|
- <button class="small-button" data-copy="${escapeAttr(credential.username || '')}" type="button">复制</button>
|
|
|
+ <button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(credential.username || '')}" type="button">复制</button>
|
|
|
</div>
|
|
|
<div class="copy-row">
|
|
|
<span>Password</span>
|
|
|
${password
|
|
|
- ? `<code>${escapeHtml(password)}</code><button class="small-button" data-copy="${escapeAttr(password)}" type="button">复制</button>`
|
|
|
- : '<p class="muted">旧密码无法回显,请重新设置一次新密码后复制。</p>'}
|
|
|
+ ? `<code>${escapeHtml(password)}</code><button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(password)}" type="button">复制</button>`
|
|
|
+ : '<p class="text-secondary">旧密码无法回显,请重新设置一次新密码后复制。</p>'}
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
@@ -178,36 +317,52 @@ function renderSmtpCredentialCopy() {
|
|
|
function renderDnsCredentials() {
|
|
|
els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
|
|
|
if (!state.dnsCredentials.length) {
|
|
|
- els.dnsCredentialList.innerHTML = '<p class="muted">暂无 DNS API 凭据</p>';
|
|
|
+ els.dnsCredentialList.innerHTML = `
|
|
|
+ <div class="empty">
|
|
|
+ <div class="empty-title">暂无 DNS API 凭据</div>
|
|
|
+ <p>先在右侧新增一组凭据;添加域名时下拉框就会出现可选项。</p>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
return;
|
|
|
}
|
|
|
els.dnsCredentialList.innerHTML = state.dnsCredentials.map((credential) => `
|
|
|
- <div class="mini-row">
|
|
|
- <div>
|
|
|
- <strong>${escapeHtml(credential.name)}</strong>
|
|
|
- <span class="muted">${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')}</span>
|
|
|
- </div>
|
|
|
- <div class="button-row">
|
|
|
- <button class="small-button" data-action="test-dns" data-id="${credential.id}" type="button">测试</button>
|
|
|
- <button class="danger-button small-danger" data-action="delete-dns" data-id="${credential.id}" type="button">删除</button>
|
|
|
+ <article class="card credential-row">
|
|
|
+ <div class="card-body">
|
|
|
+ <div>
|
|
|
+ <div class="d-flex align-items-center justify-content-between gap-2 mb-1">
|
|
|
+ <strong>${escapeHtml(credential.name)}</strong>
|
|
|
+ ${badge({ className: credential.credentialSet ? 'ok' : 'warn', label: credential.credentialSet ? '已保存' : '缺密钥' })}
|
|
|
+ </div>
|
|
|
+ <span class="text-secondary">${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')} · TTL ${escapeHtml(credential.defaultTtl)}</span>
|
|
|
+ </div>
|
|
|
+ <div class="d-flex flex-wrap gap-2 justify-content-end">
|
|
|
+ <button class="btn btn-sm btn-outline-secondary" data-action="edit-dns" data-id="${credential.id}" type="button">编辑</button>
|
|
|
+ <button class="btn btn-sm btn-outline-secondary" data-action="test-dns" data-id="${credential.id}" type="button">测试</button>
|
|
|
+ <button class="btn btn-sm btn-outline-danger" data-action="delete-dns" data-id="${credential.id}" type="button">删除</button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
+ </article>
|
|
|
`).join('');
|
|
|
}
|
|
|
|
|
|
function renderApiTokens() {
|
|
|
els.apiTokenCount.textContent = String(state.apiTokens.length);
|
|
|
if (!state.apiTokens.length) {
|
|
|
- els.apiTokenList.innerHTML = '<p class="muted">暂无发送 Token</p>';
|
|
|
+ els.apiTokenList.innerHTML = `
|
|
|
+ <div class="empty">
|
|
|
+ <div class="empty-title">暂无发送 Token</div>
|
|
|
+ <p>创建后请立即保存完整 Token,页面之后只显示前缀。</p>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
return;
|
|
|
}
|
|
|
els.apiTokenList.innerHTML = state.apiTokens.map((token) => `
|
|
|
<div class="mini-row">
|
|
|
<div>
|
|
|
<strong>${escapeHtml(token.name)}</strong>
|
|
|
- <span class="muted">${escapeHtml(token.tokenPrefix)}... · ${escapeHtml(formatDate(token.createdAt))}</span>
|
|
|
+ <span class="text-secondary">${escapeHtml(token.tokenPrefix)}... · ${escapeHtml(formatDate(token.createdAt))}</span>
|
|
|
</div>
|
|
|
- <button class="danger-button small-danger" data-action="delete-token" data-id="${token.id}" type="button">删除</button>
|
|
|
+ <button class="btn btn-sm btn-outline-danger" data-action="delete-token" data-id="${token.id}" type="button">删除</button>
|
|
|
</div>
|
|
|
`).join('');
|
|
|
}
|
|
|
@@ -221,61 +376,97 @@ function renderAdminPanel() {
|
|
|
const settings = state.settings || state.config || {};
|
|
|
els.adminPanel.classList.remove('hidden');
|
|
|
els.adminPanel.innerHTML = `
|
|
|
- <form class="compact-form" data-form="admin-settings">
|
|
|
- <div class="section-head">
|
|
|
- <h2>系统设置</h2>
|
|
|
- <span class="count">${state.users.length}</span>
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">系统默认值</h3>
|
|
|
+ <p class="card-subtitle">这些值会作为新域名的默认 DNS 和发信参数。</p>
|
|
|
</div>
|
|
|
- <label>APP Base URL<input name="appBaseUrl" value="${escapeAttr(settings.appBaseUrl || '')}"></label>
|
|
|
- <label>发信主机<input name="mailHostname" value="${escapeAttr(settings.mailHostname || '')}"></label>
|
|
|
- <label>发信 IP<input name="sendingIp" value="${escapeAttr(settings.sendingIp || '')}"></label>
|
|
|
- <label>默认 SPF<textarea name="defaultSpfMechanisms" rows="2">${escapeHtml(settings.defaultSpfMechanisms || '')}</textarea></label>
|
|
|
- <div class="form-grid">
|
|
|
- <label>DMARC
|
|
|
- <select name="dmarcPolicy">
|
|
|
- ${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${settings.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}
|
|
|
- </select>
|
|
|
- </label>
|
|
|
- <label>验证后发信
|
|
|
- <select name="sendRequiresVerified">
|
|
|
- <option value="false" ${!settings.sendRequiresVerified ? 'selected' : ''}>否</option>
|
|
|
- <option value="true" ${settings.sendRequiresVerified ? 'selected' : ''}>是</option>
|
|
|
- </select>
|
|
|
- </label>
|
|
|
+ <div class="card-actions">
|
|
|
+ <span class="badge bg-blue-lt">${state.users.length} 用户</span>
|
|
|
</div>
|
|
|
- <label>DMARC rua<input name="dmarcRua" value="${escapeAttr(settings.dmarcRua || '')}"></label>
|
|
|
- <button class="primary-button" type="submit">保存系统设置</button>
|
|
|
- </form>
|
|
|
- <div class="mini-list user-mini-list">
|
|
|
- ${state.users.slice(0, 8).map((user) => `
|
|
|
+ </div>
|
|
|
+ <div class="card-body">
|
|
|
+ <form class="compact-form" data-form="admin-settings">
|
|
|
+ <div class="mb-3">
|
|
|
+ <label class="form-label">APP Base URL</label>
|
|
|
+ <input class="form-control" name="appBaseUrl" value="${escapeAttr(settings.appBaseUrl || '')}">
|
|
|
+ <div class="form-hint">API 示例和外部访问地址。</div>
|
|
|
+ </div>
|
|
|
+ <div class="row">
|
|
|
+ <div class="col-sm-6 mb-3">
|
|
|
+ <label class="form-label">发信主机</label>
|
|
|
+ <input class="form-control" name="mailHostname" value="${escapeAttr(settings.mailHostname || '')}">
|
|
|
+ <div class="form-hint">建议使用 mail.example.com。</div>
|
|
|
+ </div>
|
|
|
+ <div class="col-sm-6 mb-3">
|
|
|
+ <label class="form-label">发信 IP</label>
|
|
|
+ <input class="form-control" name="sendingIp" value="${escapeAttr(settings.sendingIp || '')}">
|
|
|
+ <div class="form-hint">服务器公网 IPv4。</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="mb-3">
|
|
|
+ <label class="form-label">默认 SPF</label>
|
|
|
+ <textarea class="form-control" name="defaultSpfMechanisms" rows="2">${escapeHtml(settings.defaultSpfMechanisms || '')}</textarea>
|
|
|
+ <div class="form-hint">会合并到 v=spf1 中,不要重复写 v=spf1。</div>
|
|
|
+ </div>
|
|
|
+ <div class="row">
|
|
|
+ <div class="col-sm-6 mb-3">
|
|
|
+ <label class="form-label">DMARC</label>
|
|
|
+ <select class="form-select" name="dmarcPolicy">
|
|
|
+ ${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${settings.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="col-sm-6 mb-3">
|
|
|
+ <label class="form-label">验证后发信</label>
|
|
|
+ <select class="form-select" name="sendRequiresVerified">
|
|
|
+ <option value="false" ${!settings.sendRequiresVerified ? 'selected' : ''}>否</option>
|
|
|
+ <option value="true" ${settings.sendRequiresVerified ? 'selected' : ''}>是</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="mb-3">
|
|
|
+ <label class="form-label">DMARC rua</label>
|
|
|
+ <input class="form-control" name="dmarcRua" value="${escapeAttr(settings.dmarcRua || '')}" placeholder="mailto:dmarc@example.com">
|
|
|
+ </div>
|
|
|
+ <button class="btn btn-primary" type="submit">保存系统设置</button>
|
|
|
+ </form>
|
|
|
+ </div>
|
|
|
+ <div class="card-header border-top">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">用户状态</h3>
|
|
|
+ <p class="card-subtitle">禁用用户后,该用户的控制台、API Token 和 SMTP 认证都会失效。</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="card-body">
|
|
|
+ <div class="mini-list user-mini-list">
|
|
|
+ ${state.users.slice(0, 12).map((user) => `
|
|
|
<div class="mini-row">
|
|
|
<div>
|
|
|
<strong>${escapeHtml(user.username)}</strong>
|
|
|
- <span class="muted">${escapeHtml(user.email)} · ${escapeHtml(user.role)} · ${escapeHtml(user.status)}</span>
|
|
|
+ <span class="text-secondary">${escapeHtml(user.email)} · ${escapeHtml(user.role)} · ${escapeHtml(user.status)}</span>
|
|
|
</div>
|
|
|
- <button class="${user.status === 'active' ? 'danger-button' : 'small-button'} small-danger" data-action="toggle-user" data-id="${user.id}" data-status="${user.status}" type="button">
|
|
|
+ <button class="btn btn-sm ${user.status === 'active' ? 'btn-outline-danger' : 'btn-outline-secondary'}" data-action="toggle-user" data-id="${user.id}" data-status="${user.status}" type="button">
|
|
|
${user.status === 'active' ? '禁用' : '启用'}
|
|
|
</button>
|
|
|
</div>
|
|
|
`).join('')}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderDomainList() {
|
|
|
if (!state.domains.length) {
|
|
|
- els.domainList.innerHTML = '<p class="muted">暂无域名</p>';
|
|
|
+ els.domainList.innerHTML = '<div class="empty-mini">暂无域名</div>';
|
|
|
return;
|
|
|
}
|
|
|
els.domainList.innerHTML = state.domains.map((domain) => {
|
|
|
const status = statusMeta(domain.status);
|
|
|
return `
|
|
|
<button class="domain-item ${domain.id === state.selectedId ? 'active' : ''}" data-domain-id="${domain.id}" type="button">
|
|
|
- <div class="item-line">
|
|
|
- <strong>${escapeHtml(domain.domain)}</strong>
|
|
|
- <span class="badge ${status.className}">${status.label}</span>
|
|
|
- </div>
|
|
|
- <span class="muted">${escapeHtml(domain.selector)}._domainkey</span>
|
|
|
+ <span>${escapeHtml(domain.domain)}</span>
|
|
|
+ ${badge(status)}
|
|
|
+ <small>${escapeHtml(domain.selector)}._domainkey</small>
|
|
|
</button>
|
|
|
`;
|
|
|
}).join('');
|
|
|
@@ -296,67 +487,83 @@ function renderDetail() {
|
|
|
const records = guide.records || [];
|
|
|
const warnings = guide.warnings || [];
|
|
|
const checkedAt = guide.checkedAt ? formatDate(guide.checkedAt) : '尚未检查';
|
|
|
+ const applyDisabled = domain.dnsCredentialId ? '' : 'disabled';
|
|
|
+ const applyTitle = domain.dnsCredentialId ? '一键配置 DNS' : '请先在域名设置里绑定 DNS API 凭据';
|
|
|
els.detailPanel.innerHTML = `
|
|
|
- <div class="detail-header">
|
|
|
- <div class="detail-title">
|
|
|
- <h2>${escapeHtml(domain.domain)}</h2>
|
|
|
+ <div class="card-header detail-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">${escapeHtml(domain.domain)}</h3>
|
|
|
<div class="status-strip">
|
|
|
${badge(statusMeta(guide))}
|
|
|
- <span class="badge idle">最近检查 ${escapeHtml(checkedAt)}</span>
|
|
|
- <span class="badge idle">Selector ${escapeHtml(domain.selector)}</span>
|
|
|
+ <span class="badge bg-secondary-lt">最近检查 ${escapeHtml(checkedAt)}</span>
|
|
|
+ <span class="badge bg-secondary-lt">Selector ${escapeHtml(domain.selector)}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div class="detail-actions">
|
|
|
- <button class="primary-button" data-action="apply-dns" type="button">一键配置 DNS</button>
|
|
|
- <button class="secondary-button" data-action="check" type="button">立即检查</button>
|
|
|
- <button class="secondary-button" data-action="rotate-dkim" type="button">轮换 DKIM</button>
|
|
|
- <button class="danger-button" data-action="delete-domain" type="button">删除</button>
|
|
|
+ <div class="card-actions detail-actions">
|
|
|
+ <button class="btn btn-primary" data-action="apply-dns" title="${escapeAttr(applyTitle)}" type="button" ${applyDisabled}>一键配置 DNS</button>
|
|
|
+ <button class="btn btn-outline-secondary" data-action="check" type="button">立即检查</button>
|
|
|
+ <button class="btn btn-outline-secondary" data-action="rotate-dkim" type="button">轮换 DKIM</button>
|
|
|
+ <button class="btn btn-outline-danger" data-action="delete-domain" type="button">删除</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div class="detail-body">
|
|
|
+ <div class="card-body detail-body">
|
|
|
${renderSetupOverview(domain, guide, records)}
|
|
|
${guide.apply ? renderApplyResult(guide.apply) : ''}
|
|
|
${warnings.length ? renderWarnings(warnings) : ''}
|
|
|
- <div class="grid-two">
|
|
|
- <section class="subpanel">
|
|
|
- <div class="subpanel-head">
|
|
|
- <h3>配置引导</h3>
|
|
|
- <button class="small-button" data-action="copy-all-dns" type="button">复制全部</button>
|
|
|
+ <div class="row row-cards">
|
|
|
+ <section class="col-lg-7">
|
|
|
+ <div class="card subpanel">
|
|
|
+ <div class="card-header">
|
|
|
+ <h3 class="card-title">配置引导</h3>
|
|
|
+ <div class="card-actions"><button class="btn btn-sm btn-outline-secondary" data-action="copy-all-dns" type="button">复制全部</button></div>
|
|
|
+ </div>
|
|
|
+ <div class="card-body">
|
|
|
+ ${renderDnsGuide(records)}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- ${renderDnsGuide(records)}
|
|
|
</section>
|
|
|
- <section class="subpanel">
|
|
|
- <h3>域名设置</h3>
|
|
|
- ${renderSettingsForm(domain)}
|
|
|
+ <section class="col-lg-5">
|
|
|
+ <div class="card subpanel">
|
|
|
+ <div class="card-header"><h3 class="card-title">域名设置</h3></div>
|
|
|
+ <div class="card-body">${renderSettingsForm(domain)}</div>
|
|
|
+ </div>
|
|
|
</section>
|
|
|
</div>
|
|
|
- <div class="grid-two">
|
|
|
- <section class="subpanel">
|
|
|
- <h3>当前 DNS</h3>
|
|
|
- ${renderLiveDns(guide.live)}
|
|
|
+ <div class="row row-cards">
|
|
|
+ <section class="col-lg-6">
|
|
|
+ <div class="card subpanel">
|
|
|
+ <div class="card-header"><h3 class="card-title">当前 DNS</h3></div>
|
|
|
+ <div class="card-body">${renderLiveDns(guide.live)}</div>
|
|
|
+ </div>
|
|
|
</section>
|
|
|
- <section class="subpanel">
|
|
|
- <h3>测试发送</h3>
|
|
|
- ${renderSendForm(domain)}
|
|
|
+ <section class="col-lg-6">
|
|
|
+ <div class="card subpanel">
|
|
|
+ <div class="card-header"><h3 class="card-title">测试发送</h3></div>
|
|
|
+ <div class="card-body">${renderSendForm(domain)}</div>
|
|
|
+ </div>
|
|
|
</section>
|
|
|
</div>
|
|
|
- <div class="grid-two">
|
|
|
- <section class="subpanel api-box">
|
|
|
- <h3>发送 API</h3>
|
|
|
- <pre><code>${escapeHtml(apiExample(domain))}</code></pre>
|
|
|
+ <div class="row row-cards">
|
|
|
+ <section class="col-lg-6">
|
|
|
+ <div class="card subpanel api-box">
|
|
|
+ <div class="card-header"><h3 class="card-title">发送 API</h3></div>
|
|
|
+ <div class="card-body"><pre><code>${escapeHtml(apiExample(domain))}</code></pre></div>
|
|
|
+ </div>
|
|
|
</section>
|
|
|
- <section class="subpanel">
|
|
|
- <h3>SMTP 发信</h3>
|
|
|
- ${renderSmtpBox(domain)}
|
|
|
+ <section class="col-lg-6">
|
|
|
+ <div class="card subpanel">
|
|
|
+ <div class="card-header"><h3 class="card-title">SMTP 发信</h3></div>
|
|
|
+ <div class="card-body">${renderSmtpBox(domain)}</div>
|
|
|
+ </div>
|
|
|
</section>
|
|
|
</div>
|
|
|
- <section class="subpanel">
|
|
|
- <h3>最近发送</h3>
|
|
|
- ${renderEvents(domain)}
|
|
|
+ <section class="card subpanel">
|
|
|
+ <div class="card-header"><h3 class="card-title">最近发送</h3></div>
|
|
|
+ <div class="card-body">${renderEvents(domain)}</div>
|
|
|
</section>
|
|
|
- <section class="subpanel">
|
|
|
- <h3>增强安全记录</h3>
|
|
|
- ${renderOptionalRecords(guide.optionalRecords || [])}
|
|
|
+ <section class="card subpanel">
|
|
|
+ <div class="card-header"><h3 class="card-title">增强安全记录</h3></div>
|
|
|
+ <div class="card-body">${renderOptionalRecords(guide.optionalRecords || [])}</div>
|
|
|
</section>
|
|
|
</div>
|
|
|
`;
|
|
|
@@ -367,7 +574,7 @@ function renderSetupOverview(domain, guide, records) {
|
|
|
const cards = important.map(([key, label]) => {
|
|
|
const record = records.find((item) => item.key === key);
|
|
|
const meta = statusMeta(record || {});
|
|
|
- return `<div class="metric-card"><span>${escapeHtml(label)}</span><strong>${record ? escapeHtml(meta.label) : '待生成'}</strong></div>`;
|
|
|
+ return `<div class="metric-card compact"><span>${escapeHtml(label)}</span><strong>${record ? escapeHtml(meta.label) : '待生成'}</strong></div>`;
|
|
|
}).join('');
|
|
|
const credential = state.dnsCredentials.find((item) => item.id === domain.dnsCredentialId);
|
|
|
return `
|
|
|
@@ -375,7 +582,7 @@ function renderSetupOverview(domain, guide, records) {
|
|
|
<div>
|
|
|
<span class="eyebrow">Sending domain</span>
|
|
|
<h3>${escapeHtml(domain.domain)}</h3>
|
|
|
- <p>${escapeHtml(domain.senderHost)} / ${escapeHtml(domain.sendingIp)}</p>
|
|
|
+ <p>${escapeHtml(domain.senderHost)} / ${escapeHtml(domain.sendingIp || '未设置 IP')}</p>
|
|
|
<p>${credential ? `DNS API: ${escapeHtml(credential.name)}` : 'DNS API: 未绑定'}</p>
|
|
|
</div>
|
|
|
<div class="summary-grid">${cards}</div>
|
|
|
@@ -386,17 +593,17 @@ function renderSetupOverview(domain, guide, records) {
|
|
|
function renderApplyResult(apply) {
|
|
|
const rows = apply.results || [];
|
|
|
return `
|
|
|
- <section class="subpanel">
|
|
|
- <div class="subpanel-head">
|
|
|
- <h3>一键配置结果</h3>
|
|
|
- <span class="badge ${apply.ok ? 'ok' : 'warn'}">${apply.ok ? '完成' : '部分失败'}</span>
|
|
|
+ <section class="card subpanel">
|
|
|
+ <div class="card-header">
|
|
|
+ <h3 class="card-title">一键配置结果</h3>
|
|
|
+ <div class="card-actions">${badge({ className: apply.ok ? 'ok' : 'warn', label: apply.ok ? '完成' : '部分失败' })}</div>
|
|
|
</div>
|
|
|
- <div class="mini-list">
|
|
|
+ <div class="card-body mini-list">
|
|
|
${rows.map((row) => `
|
|
|
<div class="mini-row">
|
|
|
<div>
|
|
|
<strong>${escapeHtml(row.key)} · ${escapeHtml(row.type)}</strong>
|
|
|
- <span class="muted">${escapeHtml(row.host)} · ${escapeHtml(row.detail || row.error || '')}</span>
|
|
|
+ <span class="text-secondary">${escapeHtml(row.host)} · ${escapeHtml(row.detail || row.error || '')}</span>
|
|
|
</div>
|
|
|
${badge({ className: row.ok ? 'ok' : 'failed', label: row.ok ? '成功' : '失败' })}
|
|
|
</div>
|
|
|
@@ -408,7 +615,12 @@ function renderApplyResult(apply) {
|
|
|
|
|
|
function renderDnsGuide(records) {
|
|
|
if (!records.length) {
|
|
|
- return `<div class="empty-guide"><h4>尚未生成检查结果</h4><p>点击“立即检查”后会生成域名验证、DKIM、SPF、DMARC 和 PTR 引导。</p></div>`;
|
|
|
+ return `
|
|
|
+ <div class="empty">
|
|
|
+ <div class="empty-title">尚未生成检查结果</div>
|
|
|
+ <p>点击“立即检查”后会生成域名验证、DKIM、SPF、DMARC 和 PTR 引导。</p>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
}
|
|
|
return `<div class="record-steps">${records.map((record, index) => renderRecordCard(record, index + 1)).join('')}</div>`;
|
|
|
}
|
|
|
@@ -428,7 +640,7 @@ function renderRecordCard(record, index) {
|
|
|
<div class="dns-value">
|
|
|
<span>目标值</span>
|
|
|
<code>${escapeHtml(record.value || '')}</code>
|
|
|
- <button class="small-button" data-copy="${escapeAttr(record.value || '')}" type="button">复制值</button>
|
|
|
+ <button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(record.value || '')}" type="button">复制值</button>
|
|
|
</div>
|
|
|
${current.length ? `<div class="dns-current"><span>当前值</span>${current.map((value) => `<code>${escapeHtml(value)}</code>`).join('')}</div>` : ''}
|
|
|
${warnings.length ? `<ul class="inline-warnings">${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul>` : ''}
|
|
|
@@ -438,10 +650,10 @@ function renderRecordCard(record, index) {
|
|
|
}
|
|
|
|
|
|
function renderRecordTable(records) {
|
|
|
- if (!records.length) return '<p class="muted">点击“立即检查”生成 SPF、DKIM、DMARC 和 PTR 检查结果。</p>';
|
|
|
+ if (!records.length) return '<p class="text-secondary">点击“立即检查”生成 SPF、DKIM、DMARC 和 PTR 检查结果。</p>';
|
|
|
return `
|
|
|
<div class="record-table-wrap">
|
|
|
- <table>
|
|
|
+ <table class="table table-vcenter">
|
|
|
<thead><tr><th>项目</th><th>主机</th><th>类型</th><th>目标值</th><th>状态</th><th></th></tr></thead>
|
|
|
<tbody>${records.map(renderRecordRow).join('')}</tbody>
|
|
|
</table>
|
|
|
@@ -458,69 +670,78 @@ function renderRecordRow(record) {
|
|
|
<td>${escapeHtml(record.type)}</td>
|
|
|
<td><code>${escapeHtml(record.value || '')}</code></td>
|
|
|
<td>${badge(meta)}</td>
|
|
|
- <td><button class="small-button" data-copy="${escapeAttr(record.value || '')}" type="button">复制</button></td>
|
|
|
+ <td><button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(record.value || '')}" type="button">复制</button></td>
|
|
|
</tr>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderSettingsForm(domain) {
|
|
|
const dnsOptions = [
|
|
|
- '<option value="">不绑定</option>',
|
|
|
+ '<option value="">不绑定,手动配置</option>',
|
|
|
...state.dnsCredentials.map((credential) => `<option value="${credential.id}" ${domain.dnsCredentialId === credential.id ? 'selected' : ''}>${escapeHtml(credential.name)} · ${providerLabel(credential.provider)}</option>`)
|
|
|
].join('');
|
|
|
return `
|
|
|
<form class="compact-form" data-form="settings">
|
|
|
- <label>DNS API<select name="dnsCredentialId">${dnsOptions}</select></label>
|
|
|
- <div class="form-grid">
|
|
|
- <label>DKIM selector<input name="selector" value="${escapeAttr(domain.selector)}"></label>
|
|
|
- <label>DMARC 策略
|
|
|
- <select name="dmarcPolicy">${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${domain.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}</select>
|
|
|
- </label>
|
|
|
+ <div class="mb-3">
|
|
|
+ <label class="form-label">DNS API</label>
|
|
|
+ <select class="form-select" name="dnsCredentialId">${dnsOptions}</select>
|
|
|
+ <div class="form-hint">绑定后才能使用一键配置 DNS。</div>
|
|
|
</div>
|
|
|
- <label>发信主机<input name="senderHost" value="${escapeAttr(domain.senderHost)}"></label>
|
|
|
- <label>发信 IP<input name="sendingIp" value="${escapeAttr(domain.sendingIp)}"></label>
|
|
|
- <label>兼容第三方 SPF<textarea name="spfExtra" rows="3">${escapeHtml(domain.spfExtra || '')}</textarea></label>
|
|
|
- <label>DMARC rua<input name="dmarcRua" value="${escapeAttr(domain.dmarcRua || '')}" placeholder="mailto:dmarc@example.com"></label>
|
|
|
- <button class="secondary-button" type="submit">保存设置</button>
|
|
|
+ <div class="row">
|
|
|
+ <div class="col-sm-6 mb-3">
|
|
|
+ <label class="form-label">DKIM selector</label>
|
|
|
+ <input class="form-control" name="selector" value="${escapeAttr(domain.selector)}">
|
|
|
+ <div class="form-hint">轮换 DKIM 时也会重新生成 selector。</div>
|
|
|
+ </div>
|
|
|
+ <div class="col-sm-6 mb-3">
|
|
|
+ <label class="form-label">DMARC 策略</label>
|
|
|
+ <select class="form-select" name="dmarcPolicy">${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${domain.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}</select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="mb-3"><label class="form-label">发信主机</label><input class="form-control" name="senderHost" value="${escapeAttr(domain.senderHost)}"></div>
|
|
|
+ <div class="mb-3"><label class="form-label">发信 IP</label><input class="form-control" name="sendingIp" value="${escapeAttr(domain.sendingIp)}"></div>
|
|
|
+ <div class="mb-3"><label class="form-label">兼容第三方 SPF</label><textarea class="form-control" name="spfExtra" rows="3">${escapeHtml(domain.spfExtra || '')}</textarea></div>
|
|
|
+ <div class="mb-3"><label class="form-label">DMARC rua</label><input class="form-control" name="dmarcRua" value="${escapeAttr(domain.dmarcRua || '')}" placeholder="mailto:dmarc@example.com"></div>
|
|
|
+ <button class="btn btn-outline-secondary" type="submit">保存设置</button>
|
|
|
</form>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderLiveDns(live) {
|
|
|
- if (!live) return '<p class="muted">暂无检查数据</p>';
|
|
|
+ if (!live) return '<p class="text-secondary">暂无检查数据</p>';
|
|
|
const rows = [['根域 TXT', live.rootTxt], ['验证 TXT', live.verificationTxt], ['DKIM TXT', live.dkimTxt], ['DMARC TXT', live.dmarcTxt], ['发信主机 A', live.senderA], ['发信 IP PTR', live.ptr]];
|
|
|
- return `<div class="live-list">${rows.map(([label, values]) => `<div class="live-row"><span>${label}</span>${(values && values.length) ? values.map((value) => `<code>${escapeHtml(value)}</code>`).join('') : '<p class="muted">未发现</p>'}</div>`).join('')}</div>`;
|
|
|
+ return `<div class="live-list">${rows.map(([label, values]) => `<div class="live-row"><span>${label}</span>${(values && values.length) ? values.map((value) => `<code>${escapeHtml(value)}</code>`).join('') : '<p class="text-secondary">未发现</p>'}</div>`).join('')}</div>`;
|
|
|
}
|
|
|
|
|
|
function renderSendForm(domain) {
|
|
|
return `
|
|
|
<form class="send-form" data-form="send">
|
|
|
- <label>From<input name="from" value="noreply@${escapeAttr(domain.domain)}"></label>
|
|
|
- <label>To<input name="to" placeholder="user@example.com" required></label>
|
|
|
- <label>Subject<input name="subject" value="MailHub test for ${escapeAttr(domain.domain)}"></label>
|
|
|
- <label>Text<textarea name="text" rows="5">This is a MailHub test message from ${escapeHtml(domain.domain)}.</textarea></label>
|
|
|
- <button class="primary-button" type="submit">发送测试</button>
|
|
|
+ <div class="mb-3"><label class="form-label">From</label><input class="form-control" name="from" value="noreply@${escapeAttr(domain.domain)}"></div>
|
|
|
+ <div class="mb-3"><label class="form-label">To</label><input class="form-control" name="to" placeholder="user@example.com" required></div>
|
|
|
+ <div class="mb-3"><label class="form-label">Subject</label><input class="form-control" name="subject" value="MailHub test for ${escapeAttr(domain.domain)}"></div>
|
|
|
+ <div class="mb-3"><label class="form-label">Text</label><textarea class="form-control" name="text" rows="5">This is a MailHub test message from ${escapeHtml(domain.domain)}.</textarea></div>
|
|
|
+ <button class="btn btn-primary" type="submit">发送测试</button>
|
|
|
</form>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function renderEvents(domain) {
|
|
|
const events = state.events.filter((event) => event.domain === domain.domain).slice(0, 8);
|
|
|
- if (!events.length) return '<p class="muted">暂无发送记录</p>';
|
|
|
- return `<div class="event-list">${events.map((event) => `<div class="event-row"><div class="item-line"><strong>${escapeHtml(event.subject)}</strong>${badge({ className: event.status === 'queued' ? 'ok' : 'failed', label: event.status })}</div><span class="muted">${escapeHtml(event.sender)} -> ${escapeHtml((event.recipients || []).join(', '))}</span><span class="muted">${escapeHtml(formatDate(event.createdAt))}</span></div>`).join('')}</div>`;
|
|
|
+ if (!events.length) return '<p class="text-secondary">暂无发送记录</p>';
|
|
|
+ return `<div class="event-list">${events.map((event) => `<div class="event-row"><div class="item-line"><strong>${escapeHtml(event.subject)}</strong>${badge({ className: event.status === 'queued' ? 'ok' : 'failed', label: event.status })}</div><span class="text-secondary">${escapeHtml(event.sender)} -> ${escapeHtml((event.recipients || []).join(', '))}</span><span class="text-secondary">${escapeHtml(formatDate(event.createdAt))}</span></div>`).join('')}</div>`;
|
|
|
}
|
|
|
|
|
|
function renderSmtpBox(domain) {
|
|
|
const submission = state.config?.submission;
|
|
|
const credential = state.smtpCredential;
|
|
|
const password = credential?.password || '';
|
|
|
- if (!submission?.enabled) return '<p class="muted">SMTP Submission 未启用。</p>';
|
|
|
+ if (!submission?.enabled) return '<p class="text-secondary">SMTP Submission 未启用。</p>';
|
|
|
return `
|
|
|
<div class="smtp-grid">
|
|
|
<div class="smtp-row"><span>Host</span><code>${escapeHtml(submission.host)}</code></div>
|
|
|
<div class="smtp-row"><span>Ports</span>${(submission.ports || []).map((item) => `<code>${escapeHtml(item.port)} · ${escapeHtml(item.protocol)}</code>`).join('')}</div>
|
|
|
- <div class="smtp-row"><span>Username</span><div class="copy-row inline"><code>${escapeHtml(credential?.username || submission.username || '')}</code><button class="small-button" data-copy="${escapeAttr(credential?.username || submission.username || '')}" type="button">复制</button></div></div>
|
|
|
- <div class="smtp-row"><span>Password</span>${password ? `<div class="copy-row inline"><code>${escapeHtml(password)}</code><button class="small-button" data-copy="${escapeAttr(password)}" type="button">复制</button></div>` : '<p class="muted">旧密码无法回显,请在左侧重新设置一次新密码后复制。</p>'}</div>
|
|
|
+ <div class="smtp-row"><span>Username</span><div class="copy-row inline"><code>${escapeHtml(credential?.username || submission.username || '')}</code><button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(credential?.username || submission.username || '')}" type="button">复制</button></div></div>
|
|
|
+ <div class="smtp-row"><span>Password</span>${password ? `<div class="copy-row inline"><code>${escapeHtml(password)}</code><button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(password)}" type="button">复制</button></div>` : '<p class="text-secondary">旧密码无法回显,请在 SMTP 页面重新设置一次新密码后复制。</p>'}</div>
|
|
|
<div class="smtp-row"><span>AUTH</span><code>${submission.requireTlsForAuth ? '需要 TLS 后认证' : '允许明文认证'}</code></div>
|
|
|
<div class="smtp-row"><span>From</span><code>noreply@${escapeHtml(domain.domain)}</code></div>
|
|
|
</div>
|
|
|
@@ -528,7 +749,7 @@ function renderSmtpBox(domain) {
|
|
|
}
|
|
|
|
|
|
function renderOptionalRecords(records) {
|
|
|
- if (!records.length) return '<p class="muted">完成基础发信配置后可逐步启用。</p>';
|
|
|
+ if (!records.length) return '<p class="text-secondary">完成基础发信配置后可逐步启用。</p>';
|
|
|
return renderRecordTable(records.map((record) => ({ ...record, status: 'idle' })));
|
|
|
}
|
|
|
|
|
|
@@ -584,15 +805,55 @@ 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();
|
|
|
+ const id = Number(data.id || 0);
|
|
|
+ delete data.id;
|
|
|
+ const path = id ? `/api/dns-credentials/${id}` : '/api/dns-credentials';
|
|
|
+ const method = id ? 'PATCH' : 'POST';
|
|
|
+ const result = await api(path, { method, body: JSON.stringify(data) });
|
|
|
+ if (id) {
|
|
|
+ state.dnsCredentials = state.dnsCredentials.map((item) => item.id === id ? result.credential : item);
|
|
|
+ } else {
|
|
|
+ state.dnsCredentials.unshift(result.credential);
|
|
|
+ }
|
|
|
+ resetDnsCredentialForm();
|
|
|
renderDefaults();
|
|
|
render();
|
|
|
- toast('DNS 凭据已保存');
|
|
|
+ toast(id ? 'DNS API 已更新' : 'DNS API 已新增');
|
|
|
});
|
|
|
}
|
|
|
|
|
|
+function editDnsCredential(id) {
|
|
|
+ const credential = state.dnsCredentials.find((item) => item.id === id);
|
|
|
+ if (!credential) return;
|
|
|
+ setView('dns');
|
|
|
+ resetDnsCredentialForm();
|
|
|
+ els.dnsCredentialIdField.value = credential.id;
|
|
|
+ els.dnsCredentialName.value = credential.name || '';
|
|
|
+ els.dnsProvider.value = credential.provider || 'cloudflare';
|
|
|
+ els.dnsZoneName.value = credential.zoneName || '';
|
|
|
+ els.dnsDefaultTtl.value = credential.defaultTtl || 600;
|
|
|
+ els.dnsCredentialFormTitle.textContent = `编辑 ${credential.name}`;
|
|
|
+ els.dnsCredentialFormHint.textContent = '密钥字段留空会保留原值。';
|
|
|
+ toggleDnsProviderFields();
|
|
|
+ els.dnsCredentialName.focus();
|
|
|
+}
|
|
|
+
|
|
|
+function resetDnsCredentialForm() {
|
|
|
+ els.dnsCredentialForm.reset();
|
|
|
+ els.dnsCredentialIdField.value = '';
|
|
|
+ els.dnsDefaultTtl.value = 600;
|
|
|
+ els.dnsCredentialFormTitle.textContent = '新增 DNS API';
|
|
|
+ els.dnsCredentialFormHint.textContent = '选择服务商后只填写对应字段。';
|
|
|
+ toggleDnsProviderFields();
|
|
|
+}
|
|
|
+
|
|
|
+function toggleDnsProviderFields() {
|
|
|
+ const provider = els.dnsProvider.value || 'cloudflare';
|
|
|
+ for (const fieldset of document.querySelectorAll('[data-provider-field]')) {
|
|
|
+ fieldset.classList.toggle('hidden', fieldset.dataset.providerField !== provider);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
async function createApiToken(event) {
|
|
|
event.preventDefault();
|
|
|
await mutate(async () => {
|
|
|
@@ -618,6 +879,12 @@ function generateSmtpPassword() {
|
|
|
}
|
|
|
|
|
|
async function handleGlobalClick(event) {
|
|
|
+ const viewButton = event.target.closest('[data-view]');
|
|
|
+ if (viewButton && !event.target.closest('[data-domain-id]')) {
|
|
|
+ event.preventDefault();
|
|
|
+ setView(viewButton.dataset.view);
|
|
|
+ return;
|
|
|
+ }
|
|
|
const copyButton = event.target.closest('[data-copy]');
|
|
|
if (copyButton) {
|
|
|
event.preventDefault();
|
|
|
@@ -627,18 +894,20 @@ async function handleGlobalClick(event) {
|
|
|
toast('已复制');
|
|
|
return;
|
|
|
}
|
|
|
- const action = event.target.closest('[data-action]')?.dataset.action;
|
|
|
+ const actionTarget = event.target.closest('[data-action]');
|
|
|
+ const action = actionTarget?.dataset.action;
|
|
|
if (!action) return;
|
|
|
- const id = Number(event.target.closest('[data-id]')?.dataset.id || 0);
|
|
|
+ const id = Number(actionTarget.closest('[data-id]')?.dataset.id || actionTarget.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 === 'edit-dns') return editDnsCredential(id);
|
|
|
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);
|
|
|
+ if (action === 'toggle-user') return toggleUser(id, actionTarget.closest('[data-status]')?.dataset.status);
|
|
|
}
|
|
|
|
|
|
async function handleDetailSubmit(event) {
|
|
|
@@ -689,6 +958,7 @@ async function sendTest(form) {
|
|
|
await api(`/api/domains/${id}/test-send`, { method: 'POST', body: JSON.stringify(data) });
|
|
|
const events = await api('/api/events');
|
|
|
state.events = events.events || [];
|
|
|
+ renderDefaults();
|
|
|
render();
|
|
|
toast('已提交到发信队列');
|
|
|
});
|
|
|
@@ -725,6 +995,7 @@ async function deleteSelected() {
|
|
|
await api(`/api/domains/${id}`, { method: 'DELETE' });
|
|
|
state.domains = state.domains.filter((item) => item.id !== id);
|
|
|
state.selectedId = state.domains[0]?.id || null;
|
|
|
+ renderDefaults();
|
|
|
render();
|
|
|
});
|
|
|
}
|
|
|
@@ -737,11 +1008,13 @@ async function testDnsCredential(id) {
|
|
|
}
|
|
|
|
|
|
async function deleteDnsCredential(id) {
|
|
|
- if (!confirm('删除该 DNS API 凭据?')) return;
|
|
|
+ const credential = state.dnsCredentials.find((item) => item.id === id);
|
|
|
+ if (!confirm(`删除 ${credential?.name || '该 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);
|
|
|
+ if (Number(els.dnsCredentialIdField.value || 0) === id) resetDnsCredentialForm();
|
|
|
renderDefaults();
|
|
|
render();
|
|
|
});
|
|
|
@@ -789,7 +1062,7 @@ async function mutate(fn) {
|
|
|
try {
|
|
|
await fn();
|
|
|
} catch (error) {
|
|
|
- toast(error.message);
|
|
|
+ toast(error.message, 'error');
|
|
|
} finally {
|
|
|
setBusy(false);
|
|
|
}
|
|
|
@@ -825,7 +1098,17 @@ function statusMeta(target) {
|
|
|
}
|
|
|
|
|
|
function badge(meta) {
|
|
|
- return `<span class="badge ${meta.className}">${escapeHtml(meta.label)}</span>`;
|
|
|
+ return `<span class="badge ${badgeTone(meta.className)}">${escapeHtml(meta.label)}</span>`;
|
|
|
+}
|
|
|
+
|
|
|
+function badgeTone(className) {
|
|
|
+ return {
|
|
|
+ ok: 'bg-green-lt',
|
|
|
+ warn: 'bg-yellow-lt',
|
|
|
+ missing: 'bg-red-lt',
|
|
|
+ failed: 'bg-red-lt',
|
|
|
+ idle: 'bg-secondary-lt'
|
|
|
+ }[className] || 'bg-secondary-lt';
|
|
|
}
|
|
|
|
|
|
function providerLabel(provider) {
|
|
|
@@ -836,18 +1119,15 @@ function setBusy(value) {
|
|
|
state.busy = value;
|
|
|
document.body.classList.toggle('busy', value);
|
|
|
els.refreshButton.disabled = value;
|
|
|
+ if (els.refreshButtonMobile) els.refreshButtonMobile.disabled = value;
|
|
|
}
|
|
|
|
|
|
-function toast(message) {
|
|
|
+function toast(message, tone = 'info') {
|
|
|
const node = document.createElement('div');
|
|
|
- node.className = 'notice';
|
|
|
+ node.className = `toast ${tone}`;
|
|
|
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);
|
|
|
+ els.toastHost.appendChild(node);
|
|
|
+ setTimeout(() => node.remove(), 3200);
|
|
|
}
|
|
|
|
|
|
function formatDate(value) {
|