Initial commit for mail-sr
This commit is contained in:
481
public/app.js
Normal file
481
public/app.js
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '未知时间';
|
||||
return new Date(value).toLocaleString('zh-CN');
|
||||
}
|
||||
219
public/index.html
Normal file
219
public/index.html
Normal file
@@ -0,0 +1,219 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mail SR - 多邮箱工作台</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 左侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M2 8L12 14L22 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>Mail SR</span>
|
||||
</div>
|
||||
<button id="compose-btn" class="compose-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
写邮件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="account-section">
|
||||
<div class="section-header">
|
||||
<span>我的邮箱</span>
|
||||
</div>
|
||||
<div id="channel-tree" class="account-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button id="settings-btn" class="icon-btn" title="设置">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82.33l.06.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 邮件列表区 -->
|
||||
<section class="mail-list-section">
|
||||
<header class="mail-list-header">
|
||||
<div class="header-left">
|
||||
<h2 id="selected-account-title">选择邮箱帐号</h2>
|
||||
<span id="selected-account-meta" class="account-info"></span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button id="refresh-mails-btn" class="action-btn" disabled title="刷新">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 20 10"></polyline>
|
||||
<polyline points="1 20 1 14 4 14"></polyline>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="load-mails-btn" class="action-btn" disabled title="加载缓存">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mail-list">
|
||||
<div id="message-list" class="message-items">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 邮件详情区 -->
|
||||
<section class="mail-detail-section">
|
||||
<article id="message-detail" class="mail-detail empty">
|
||||
<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>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 写邮件弹窗 -->
|
||||
<div id="compose-modal" class="modal hidden">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content compose-modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>写邮件</h3>
|
||||
<button class="close-btn" id="close-compose">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="send-form" class="compose-form">
|
||||
<div class="form-row">
|
||||
<label>收件人</label>
|
||||
<input type="text" name="to" placeholder="多个收件人用逗号分隔" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>抄送</label>
|
||||
<input type="text" name="cc" placeholder="可选">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>主题</label>
|
||||
<input type="text" name="subject" placeholder="邮件主题" required>
|
||||
</div>
|
||||
<div class="form-row body-row">
|
||||
<textarea name="text" placeholder="撰写邮件内容..." required></textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="send-btn" id="send-btn" disabled>发送</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置弹窗 -->
|
||||
<div id="settings-modal" class="modal hidden">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content settings-modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>设置</h3>
|
||||
<button class="close-btn" id="close-settings">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-section">
|
||||
<h4>新增邮箱渠道</h4>
|
||||
<form id="channel-form" class="settings-form">
|
||||
<div class="form-row compact">
|
||||
<input name="name" placeholder="渠道名称,如 QQ邮箱" required>
|
||||
</div>
|
||||
<div class="form-row compact">
|
||||
<input name="code" placeholder="渠道标识,如 qq" required>
|
||||
</div>
|
||||
<div class="form-row compact">
|
||||
<input name="imap_host" placeholder="IMAP Host" required>
|
||||
</div>
|
||||
<div class="form-row compact split">
|
||||
<input name="imap_port" type="number" placeholder="IMAP Port" required>
|
||||
<label class="checkbox"><input name="imap_secure" type="checkbox" checked> SSL</label>
|
||||
</div>
|
||||
<div class="form-row compact">
|
||||
<input name="smtp_host" placeholder="SMTP Host" required>
|
||||
</div>
|
||||
<div class="form-row compact split">
|
||||
<input name="smtp_port" type="number" placeholder="SMTP Port" required>
|
||||
<label class="checkbox"><input name="smtp_secure" type="checkbox" checked> SSL</label>
|
||||
</div>
|
||||
<button type="submit" class="add-btn">新增渠道</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h4>导入邮箱帐号</h4>
|
||||
<form id="account-form" class="settings-form">
|
||||
<div class="form-row compact">
|
||||
<select name="channel_id" required>
|
||||
<option value="">选择邮箱渠道</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row compact">
|
||||
<input name="email" type="email" placeholder="邮箱帐号" required>
|
||||
</div>
|
||||
<div class="form-row compact">
|
||||
<input name="auth_code" type="password" placeholder="授权码" required>
|
||||
</div>
|
||||
<div class="form-row compact">
|
||||
<input name="display_name" placeholder="发件人名称(可选)">
|
||||
</div>
|
||||
<button type="submit" class="add-btn" id="account-submit-btn">导入帐号</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h4>批量导入导出</h4>
|
||||
<div class="batch-tip">每行一条,格式:渠道名----帐号----授权码</div>
|
||||
<form id="batch-import-form" class="settings-form">
|
||||
<div class="form-row compact">
|
||||
<textarea id="batch-import-content" class="batch-textarea" placeholder="例如: QQ邮箱----demo@qq.com----xxxxxxxx 163邮箱----demo@163.com----yyyyyyyy"></textarea>
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<button type="submit" class="add-btn" id="batch-import-btn">批量导入</button>
|
||||
<button type="button" class="secondary-btn" id="export-accounts-btn">导出全部</button>
|
||||
<button type="button" class="secondary-btn" id="copy-export-btn">复制导出内容</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
780
public/styles.css
Normal file
780
public/styles.css
Normal file
@@ -0,0 +1,780 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-base: #0f172a;
|
||||
--bg-sidebar: #0c1424;
|
||||
--bg-card: #1e293b;
|
||||
--bg-hover: #273548;
|
||||
--bg-active: #1e3a5f;
|
||||
--border: #334155;
|
||||
--border-light: #1e293b;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #3b82f6;
|
||||
--accent-light: #1e3a5f;
|
||||
--accent-hover: #60a5fa;
|
||||
--danger: #ef4444;
|
||||
--danger-hover: #f87171;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.35);
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 320px 1fr;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Sidebar === */
|
||||
.sidebar {
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.compose-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compose-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.compose-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.account-section {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 4px 16px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.account-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.channel-group {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.channel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 8px 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-header:hover .chevron,
|
||||
.channel-header:hover .channel-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform 0.2s, color 0.15s;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.channel-header.collapsed .chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.channel-accounts {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.2s ease;
|
||||
}
|
||||
|
||||
.channel-header.collapsed + .channel-accounts {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.account-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.account-copy-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.12s, color 0.12s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-copy-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.account-item.active .account-copy-btn {
|
||||
background: var(--bg-active);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.account-delete-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.account-item:hover .account-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.account-delete-btn:hover {
|
||||
background: #fee2e2;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* === Mail List Section === */
|
||||
.mail-list-section {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mail-list-header {
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#selected-account-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.account-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mail-list {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.message-card {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
border: 1px solid transparent;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-card:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.message-card.active {
|
||||
background: var(--accent-light);
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.message-card h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-card p {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* === Mail Detail Section === */
|
||||
.mail-detail-section {
|
||||
background: var(--bg-base);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mail-detail {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.mail-detail.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mail-detail.empty .empty-state svg {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.reading-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reading-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 16px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.reading-meta-grid p {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reading-meta-grid strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reading-divider {
|
||||
height: 1px;
|
||||
background: var(--border-light);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.message-body.text-body {
|
||||
white-space: pre-wrap;
|
||||
background: var(--bg-card);
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.message-body.html-body {
|
||||
background: var(--bg-card);
|
||||
padding: 20px 24px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.message-body.html-body img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.message-body.html-body a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* === Modal === */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md), 0 20px 40px rgba(0,0,0,0.15);
|
||||
overflow: hidden;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.compose-modal-content {
|
||||
width: 620px;
|
||||
}
|
||||
|
||||
.settings-modal-content {
|
||||
width: 520px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.compose-form,
|
||||
.settings-content {
|
||||
padding: 20px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.body-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.body-row textarea {
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-section h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.batch-tip {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.batch-textarea {
|
||||
min-height: 150px;
|
||||
resize: vertical;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-row.compact {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-row.split {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-row.split input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.add-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.secondary-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* === Toast === */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
padding: 12px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
background: #1a1d21;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 2000;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.toast.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.app-container {
|
||||
grid-template-columns: 220px 280px 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mail-list-section {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user