482 lines
16 KiB
JavaScript
482 lines
16 KiB
JavaScript
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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '未知时间';
|
|
return new Date(value).toLocaleString('zh-CN');
|
|
}
|