const state = { channels: [], accounts: [], selectedAccountId: null, messages: [], selectedMessageUid: null, authenticated: false, }; const appShell = document.getElementById('app-shell'); const loginScreen = document.getElementById('login-screen'); const loginForm = document.getElementById('login-form'); const loginBtn = document.getElementById('login-btn'); const logoutBtn = document.getElementById('logout-btn'); 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 restoreSession(); } function bindEvents() { loginForm.addEventListener('submit', onLogin); logoutBtn.addEventListener('click', onLogout); 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); } async function restoreSession() { const result = await request('/api/auth/status', {}, false); state.authenticated = Boolean(result.authenticated); updateAuthView(); if (state.authenticated) { await reloadBaseData(); } } function updateAuthView() { loginScreen.classList.toggle('hidden', state.authenticated); appShell.classList.toggle('hidden', !state.authenticated); } async function onLogin(event) { event.preventDefault(); const payload = Object.fromEntries(new FormData(loginForm).entries()); loginBtn.disabled = true; loginBtn.textContent = '验证中...'; try { await request('/api/auth/login', { method: 'POST', body: JSON.stringify(payload), }, false); state.authenticated = true; updateAuthView(); loginForm.reset(); await reloadBaseData(); showToast('登录成功'); } finally { loginBtn.disabled = false; loginBtn.textContent = '进入系统'; } } async function onLogout() { await request('/api/auth/logout', { method: 'POST' }, false); state.authenticated = false; state.selectedAccountId = null; state.selectedMessageUid = null; state.messages = []; syncSelectionState(); renderChannelTree(); renderMessages(); updateAuthView(); showToast('已退出登录'); } 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 = '' + state.channels .map((channel) => ``) .join(''); } function renderChannelTree() { if (!state.channels.length) { channelTree.innerHTML = '
暂无渠道,点击底部设置新增
'; 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) => `
`).join('') : '
该渠道下暂无邮箱
'; return `
${escapeHtml(channel.name)} ${accounts.length}
${items}
`; }).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 = `

暂无邮件,点击刷新收取

`; messageDetail.className = 'mail-detail empty'; messageDetail.innerHTML = `

选择一封邮件查看详情

`; return; } messageList.innerHTML = state.messages.map((message) => `

${escapeHtml(message.subject || '(无主题)')}

${escapeHtml(message.from?.name || message.from?.address || '')}

${escapeHtml(message.folder || 'INBOX')} · ${formatDate(message.date)}
`).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 = `

${escapeHtml(active.subject || '(无主题)')}

发件人:${escapeHtml(active.from?.name || '')} ${escapeHtml(active.from?.address || '')}

时间:${formatDate(active.date)}

收件人:${escapeHtml((active.to || []).map((item) => item.address).join(', '))}

文件夹:${escapeHtml(active.folder || 'INBOX')}

${bodyHtml.content}
`; } 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>/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 = {}, useToast = true) { const response = await fetch(url, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(options.headers || {}), }, ...options, }); const data = await response.json().catch(() => ({})); if (!response.ok) { if (useToast) { 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('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function formatDate(value) { if (!value) return '未知时间'; return new Date(value).toLocaleString('zh-CN'); }