Initial commit for mail-sr

This commit is contained in:
zeer
2026-04-25 20:51:08 +08:00
commit 37907dd2f5
14 changed files with 4628 additions and 0 deletions

481
public/app.js Normal file
View File

@@ -0,0 +1,481 @@
const state = {
channels: [],
accounts: [],
selectedAccountId: null,
messages: [],
selectedMessageUid: null,
};
const channelTree = document.getElementById('channel-tree');
const channelForm = document.getElementById('channel-form');
const accountForm = document.getElementById('account-form');
const accountSubmitBtn = document.getElementById('account-submit-btn');
const sendForm = document.getElementById('send-form');
const sendBtn = document.getElementById('send-btn');
const refreshMailsBtn = document.getElementById('refresh-mails-btn');
const loadMailsBtn = document.getElementById('load-mails-btn');
const selectedAccountTitle = document.getElementById('selected-account-title');
const selectedAccountMeta = document.getElementById('selected-account-meta');
const messageList = document.getElementById('message-list');
const messageDetail = document.getElementById('message-detail');
const toast = document.getElementById('toast');
const composeModal = document.getElementById('compose-modal');
const settingsModal = document.getElementById('settings-modal');
const composeBtn = document.getElementById('compose-btn');
const settingsBtn = document.getElementById('settings-btn');
const closeCompose = document.getElementById('close-compose');
const closeSettings = document.getElementById('close-settings');
const batchImportForm = document.getElementById('batch-import-form');
const batchImportContent = document.getElementById('batch-import-content');
const batchImportBtn = document.getElementById('batch-import-btn');
const exportAccountsBtn = document.getElementById('export-accounts-btn');
const copyExportBtn = document.getElementById('copy-export-btn');
boot();
async function boot() {
bindEvents();
await reloadBaseData();
}
function bindEvents() {
composeBtn.addEventListener('click', () => openModal(composeModal));
settingsBtn.addEventListener('click', () => openModal(settingsModal));
closeCompose.addEventListener('click', () => closeModal(composeModal));
closeSettings.addEventListener('click', () => closeModal(settingsModal));
composeModal.querySelector('.modal-backdrop').addEventListener('click', () => closeModal(composeModal));
settingsModal.querySelector('.modal-backdrop').addEventListener('click', () => closeModal(settingsModal));
channelForm.addEventListener('submit', onCreateChannel);
accountForm.addEventListener('submit', onCreateAccount);
batchImportForm.addEventListener('submit', onBatchImport);
sendForm.addEventListener('submit', onSendMail);
refreshMailsBtn.addEventListener('click', () => loadMessages(true));
loadMailsBtn.addEventListener('click', () => loadMessages(false));
exportAccountsBtn.addEventListener('click', onExportAccounts);
copyExportBtn.addEventListener('click', onCopyExportAccounts);
}
function openModal(modal) {
modal.classList.remove('hidden');
}
function closeModal(modal) {
modal.classList.add('hidden');
}
async function reloadBaseData() {
const [channels, accounts] = await Promise.all([
request('/api/channels'),
request('/api/accounts'),
]);
state.channels = channels;
state.accounts = accounts;
renderAccountOptions();
renderChannelTree();
}
async function onCreateChannel(event) {
event.preventDefault();
const formData = new FormData(channelForm);
const payload = Object.fromEntries(formData.entries());
payload.imap_secure = formData.get('imap_secure') === 'on';
payload.smtp_secure = formData.get('smtp_secure') === 'on';
await request('/api/channels', {
method: 'POST',
body: JSON.stringify(payload),
});
channelForm.reset();
channelForm.querySelector('[name="imap_secure"]').checked = true;
channelForm.querySelector('[name="smtp_secure"]').checked = true;
showToast('渠道已创建');
await reloadBaseData();
}
async function onCreateAccount(event) {
event.preventDefault();
const payload = Object.fromEntries(new FormData(accountForm).entries());
accountSubmitBtn.disabled = true;
accountSubmitBtn.textContent = '保存中...';
try {
await request('/api/accounts', {
method: 'POST',
body: JSON.stringify(payload),
});
accountForm.reset();
showToast('邮箱帐号已导入');
await reloadBaseData();
} finally {
accountSubmitBtn.disabled = false;
accountSubmitBtn.textContent = '导入帐号';
}
}
async function onSendMail(event) {
event.preventDefault();
if (!state.selectedAccountId) {
showToast('请先选择邮箱帐号', true);
return;
}
const payload = Object.fromEntries(new FormData(sendForm).entries());
sendBtn.disabled = true;
sendBtn.textContent = '发送中...';
try {
await request(`/api/accounts/${state.selectedAccountId}/messages/send`, {
method: 'POST',
body: JSON.stringify(payload),
});
sendForm.reset();
closeModal(composeModal);
showToast('邮件发送成功');
} finally {
sendBtn.disabled = false;
sendBtn.textContent = '发送';
}
}
async function onBatchImport(event) {
event.preventDefault();
const content = batchImportContent.value.trim();
if (!content) {
showToast('请先粘贴导入内容', true);
return;
}
batchImportBtn.disabled = true;
batchImportBtn.textContent = '导入中...';
try {
const result = await request('/api/accounts/import', {
method: 'POST',
body: JSON.stringify({ content }),
});
await reloadBaseData();
showToast(`导入完成,新增 ${result.created} 条,更新 ${result.updated}`);
} finally {
batchImportBtn.disabled = false;
batchImportBtn.textContent = '批量导入';
}
}
async function onExportAccounts() {
const result = await request('/api/accounts/export');
batchImportContent.value = result.content || '';
showToast('导出内容已填入文本框');
}
async function onCopyExportAccounts() {
const content = batchImportContent.value.trim();
if (!content) {
const result = await request('/api/accounts/export');
batchImportContent.value = result.content || '';
}
await copyText(batchImportContent.value);
showToast('导出内容已复制');
}
function renderAccountOptions() {
const select = accountForm.querySelector('select[name="channel_id"]');
select.innerHTML = '<option value="">选择邮箱渠道</option>' + state.channels
.map((channel) => `<option value="${channel.id}">${escapeHtml(channel.name)} (${escapeHtml(channel.code)})</option>`)
.join('');
}
function renderChannelTree() {
if (!state.channels.length) {
channelTree.innerHTML = '<div class="empty-hint">暂无渠道,点击底部设置新增</div>';
return;
}
channelTree.innerHTML = state.channels.map((channel) => {
const accounts = state.accounts.filter((account) => Number(account.channel_id) === Number(channel.id));
const items = accounts.length
? accounts.map((account) => `
<div class="account-item ${Number(state.selectedAccountId) === Number(account.id) ? 'active' : ''}">
<button
class="account-copy-btn"
data-account-id="${account.id}"
data-account-email="${escapeHtml(account.email)}"
title="点击复制邮箱号,并立即收信"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
${escapeHtml(account.email)}
</button>
<button
class="account-delete-btn"
data-delete-account-id="${account.id}"
data-delete-account-email="${escapeHtml(account.email)}"
title="删除该邮箱帐号"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
`).join('')
: '<div class="empty-hint" style="padding: 4px 8px 8px;">该渠道下暂无邮箱</div>';
return `
<div class="channel-group">
<div class="channel-header" data-channel-toggle="${channel.id}">
<span class="channel-name">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="chevron">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
${escapeHtml(channel.name)}
</span>
<span style="font-size:11px;color:var(--text-muted);font-weight:400">${accounts.length}</span>
</div>
<div class="channel-accounts" data-channel-accounts="${channel.id}">
${items}
</div>
</div>
`;
}).join('');
channelTree.querySelectorAll('[data-channel-toggle]').forEach((header) => {
header.addEventListener('click', () => {
header.classList.toggle('collapsed');
});
});
channelTree.querySelectorAll('[data-account-id]').forEach((button) => {
button.addEventListener('click', async () => {
const email = button.dataset.accountEmail || '';
let copied = false;
try {
await copyText(email);
copied = true;
} catch (_error) {
copied = false;
}
state.selectedAccountId = Number(button.dataset.accountId);
state.selectedMessageUid = null;
syncSelectionState();
renderChannelTree();
if (copied) {
showToast(`已复制: ${email}`);
}
await loadMessages(true, true);
});
});
channelTree.querySelectorAll('[data-delete-account-id]').forEach((button) => {
button.addEventListener('click', async (event) => {
event.stopPropagation();
const accountId = Number(button.dataset.deleteAccountId);
const email = button.dataset.deleteAccountEmail || '';
if (!window.confirm(`确认删除 ${email} 吗?`)) {
return;
}
await request(`/api/accounts/${accountId}`, { method: 'DELETE' });
if (Number(state.selectedAccountId) === accountId) {
state.selectedAccountId = null;
state.selectedMessageUid = null;
state.messages = [];
renderMessages();
syncSelectionState();
}
showToast(`已删除: ${email}`);
await reloadBaseData();
});
});
}
async function loadMessages(refresh, autoFocusLatest = false) {
if (!state.selectedAccountId) {
return;
}
const query = new URLSearchParams({
limit: '20',
refresh: refresh ? 'true' : 'false',
});
const messages = await request(`/api/accounts/${state.selectedAccountId}/messages?${query.toString()}`);
state.messages = messages;
if (autoFocusLatest || !state.selectedMessageUid) {
state.selectedMessageUid = messages[0]?.uid || null;
}
renderMessages();
syncSelectionState();
showToast(refresh ? '已收取最新邮件' : '已加载缓存邮件');
}
function syncSelectionState() {
const account = state.accounts.find((item) => Number(item.id) === Number(state.selectedAccountId));
const enabled = Boolean(account);
refreshMailsBtn.disabled = !enabled;
loadMailsBtn.disabled = !enabled;
sendBtn.disabled = !enabled;
if (!account) {
selectedAccountTitle.textContent = '选择邮箱帐号';
selectedAccountMeta.textContent = '左侧选择邮箱后开始操作';
return;
}
const channel = state.channels.find((item) => Number(item.id) === Number(account.channel_id));
selectedAccountTitle.textContent = account.email;
selectedAccountMeta.textContent = `${channel?.name || '未知渠道'} · ID: ${account.id}`;
}
function renderMessages() {
if (!state.messages.length) {
messageList.innerHTML = `
<div class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
<p>暂无邮件,点击刷新收取</p>
</div>`;
messageDetail.className = 'mail-detail empty';
messageDetail.innerHTML = `
<div class="empty-state">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
<p>选择一封邮件查看详情</p>
</div>`;
return;
}
messageList.innerHTML = state.messages.map((message) => `
<div class="message-card ${state.selectedMessageUid == message.uid ? 'active' : ''}" data-message-uid="${message.uid}">
<h3>${escapeHtml(message.subject || '(无主题)')}</h3>
<p>${escapeHtml(message.from?.name || message.from?.address || '')}</p>
<div class="message-meta">${escapeHtml(message.folder || 'INBOX')} · ${formatDate(message.date)}</div>
</div>
`).join('');
messageList.querySelectorAll('[data-message-uid]').forEach((node) => {
node.addEventListener('click', async () => {
state.selectedMessageUid = node.dataset.messageUid;
await loadMessageDetail(state.selectedMessageUid);
renderMessages();
});
});
const active = state.messages.find((message) => String(message.uid) === String(state.selectedMessageUid)) || state.messages[0];
if (!active) {
return;
}
messageDetail.className = 'mail-detail';
const bodyHtml = normalizeMessageBody(active);
messageDetail.innerHTML = `
<div class="reading-header">
<h3>${escapeHtml(active.subject || '(无主题)')}</h3>
</div>
<div class="reading-meta-grid">
<p><strong>发件人:</strong>${escapeHtml(active.from?.name || '')} ${escapeHtml(active.from?.address || '')}</p>
<p><strong>时间:</strong>${formatDate(active.date)}</p>
<p><strong>收件人:</strong>${escapeHtml((active.to || []).map((item) => item.address).join(', '))}</p>
<p><strong>文件夹:</strong>${escapeHtml(active.folder || 'INBOX')}</p>
</div>
<div class="reading-divider"></div>
<div class="message-body ${bodyHtml.isHtml ? 'html-body' : 'text-body'}">${bodyHtml.content}</div>
`;
}
function normalizeMessageBody(message) {
const html = String(message.html || '').trim();
const text = String(message.text || '').trim();
if (html) {
return { isHtml: true, content: sanitizeHtml(html) };
}
return { isHtml: false, content: escapeHtml(text || '该邮件暂无可展示正文') };
}
function sanitizeHtml(value) {
return String(value)
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/on\w+=/gi, 'data-blocked=');
}
async function loadMessageDetail(uid) {
if (!state.selectedAccountId || !uid) {
return;
}
try {
const detail = await request(`/api/accounts/${state.selectedAccountId}/messages/${encodeURIComponent(uid)}?refresh=true`);
state.messages = state.messages.map((message) => (String(message.uid) === String(uid) ? detail : message));
} catch (_error) {
}
}
async function copyText(value) {
if (!value) return;
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
return;
}
const input = document.createElement('textarea');
input.value = value;
input.setAttribute('readonly', 'readonly');
input.style.position = 'absolute';
input.style.left = '-9999px';
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
}
async function request(url, options = {}) {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
showToast(data.error || '请求失败', true);
throw new Error(data.error || 'Request failed');
}
return data;
}
function showToast(message, error = false) {
toast.textContent = message;
toast.className = `toast ${error ? 'error' : ''}`;
setTimeout(() => {
toast.className = 'toast hidden';
}, 2500);
}
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function formatDate(value) {
if (!value) return '未知时间';
return new Date(value).toLocaleString('zh-CN');
}