Files
outlookEmailPlus/static/js/features/accounts.js
T
胡绍康andClaude Opus 4.6 64d8a6c357 feat(multi-mailbox): 实现多邮箱统一管理功能(PRD-00005)
核心变更:
- 数据库:Schema v3,accounts 表新增 account_type/provider/imap_host/imap_port/imap_password 字段
- 后端:新增通用 IMAP 服务(imap_generic.py)与邮箱提供商配置(providers.py)
- 导入:支持 Outlook/QQ/163/Gmail/自定义 IMAP 多格式批量导入,兼容注释行与导出格式
- 导出:按邮箱类型分组导出,保留 provider 信息便于重新导入
- 邮件读取:IMAP 账号走通用 IMAP 服务,Outlook 保持原 Graph/XOAUTH2 链路
- 验证码提取:IMAP 账号通过 IMAP 获取邮件详情后提取
- 安全约束:IMAP 账号禁用远程删除,敏感字段(imap_password)加密存储
- 前端:新增邮箱类型下拉、自定义 IMAP 配置区、provider 标签显示
- 调度器:定时刷新仅处理 Outlook 账号(IMAP 无 OAuth token 刷新语义)
- 日志:配置 outlook_web 命名空间日志输出到 stderr

技术细节:
- 保持 Outlook 旧格式完全兼容(四段式:email----password----client_id----refresh_token)
- IMAP 账号使用空字符串占位 client_id/refresh_token(满足 DB NOT NULL 约束)
- 支持导出格式智能识别(5 段 custom、3 段 provider、2 段配合输入框)
- 前端 DOMPurify 未加载时回退为基本 HTML 过滤(CDN 不可达场景)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:54:39 +08:00

618 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==================== 账号相关 ====================
// 选择账号
function selectAccount(email) {
if (typeof stopPolling === 'function') {
stopPolling(true);
}
currentAccount = email;
isTempEmailGroup = false;
currentFolder = 'inbox';
document.getElementById('currentAccountBar').style.display = '';
document.getElementById('currentAccountEmail').textContent = email;
// Update active state on account cards
document.querySelectorAll('.account-card').forEach(item => {
item.classList.remove('active');
const emailEl = item.querySelector('.account-email');
if (emailEl && emailEl.textContent.includes(email)) {
item.classList.add('active');
}
});
const folderTabs = document.getElementById('folderTabs');
if (folderTabs) {
folderTabs.style.display = 'flex';
document.querySelectorAll('.email-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.folder === 'inbox');
});
}
const cacheKey = `${email}_inbox`;
if (emailListCache[cacheKey]) {
const cache = emailListCache[cacheKey];
currentEmails = cache.emails;
hasMoreEmails = cache.has_more;
currentSkip = cache.skip;
currentMethod = cache.method || 'graph';
const methodTag = document.getElementById('methodTag');
methodTag.textContent = currentMethod;
methodTag.style.display = 'inline';
document.getElementById('emailCount').textContent = `(${currentEmails.length})`;
renderEmailList(currentEmails);
} else {
document.getElementById('emailList').innerHTML = `
<div class="empty-state">
<span class="empty-icon">📬</span>
<p>点击"获取邮件"按钮获取邮件</p>
</div>
`;
document.getElementById('emailCount').textContent = '';
document.getElementById('methodTag').style.display = 'none';
currentEmails = [];
}
document.getElementById('emailDetail').innerHTML = `
<div class="empty-state">
<span class="empty-icon">📄</span>
<p>选择一封邮件查看详情</p>
</div>
`;
document.getElementById('emailDetailToolbar').style.display = 'none';
}
// Provider 下拉缓存
let providersLoaded = false;
let providerOptions = [];
// 加载邮箱 providers(用于导入下拉)
async function loadProviders() {
if (providersLoaded) return;
const select = document.getElementById('accountProvider');
if (!select) return;
try {
const resp = await fetch('/api/providers');
const data = await resp.json();
if (!data.success || !Array.isArray(data.providers)) return;
providerOptions = data.providers;
select.innerHTML = data.providers.map(p => `
<option value="${escapeHtml(p.key)}">${escapeHtml(p.label || p.key)}</option>
`).join('');
providersLoaded = true;
} catch (e) {
// 静默失败:保留默认 Outlook 选项
}
}
// Provider 切换:更新 placeholder / hint / custom IMAP 配置区显示
function onProviderChange(provider) {
const p = (provider || 'outlook').toLowerCase();
const input = document.getElementById('accountInput');
const hint = document.getElementById('accountFormatHint');
const customFields = document.getElementById('customImapFields');
const getTokenBtn = document.getElementById('getRefreshTokenBtnInAdd');
if (!input || !hint || !customFields) return;
// "获取Token"按钮仅 Outlook 类型可见
if (getTokenBtn) {
getTokenBtn.style.display = (p === 'outlook') ? '' : 'none';
}
if (p === 'outlook') {
customFields.style.display = 'none';
input.placeholder = '邮箱----密码----client_id----refresh_token';
hint.textContent = '格式:邮箱----密码----client_id----refresh_token,支持批量导入(每行一个)';
return;
}
if (p === 'custom') {
customFields.style.display = '';
input.placeholder = '邮箱----IMAP授权码/应用密码';
hint.textContent = '格式:邮箱----IMAP授权码/应用密码(每行一个)。自定义 IMAP 需填写上方服务器/端口;也支持:邮箱----授权码----imap_host----imap_port';
return;
}
customFields.style.display = 'none';
input.placeholder = '邮箱----IMAP授权码/应用密码';
hint.textContent = '格式:邮箱----IMAP授权码/应用密码,支持批量导入(每行一个)';
}
// 显示添加账号模态框
function showAddAccountModal() {
document.getElementById('accountInput').value = '';
// 设置默认分组为当前选中的分组
if (currentGroupId) {
document.getElementById('importGroupSelect').value = currentGroupId;
}
// 加载 providers 并初始化默认状态
loadProviders().finally(() => {
const sel = document.getElementById('accountProvider');
if (sel) {
sel.value = 'outlook';
onProviderChange('outlook');
} else {
onProviderChange('outlook');
}
const hostEl = document.getElementById('imapHost');
const portEl = document.getElementById('imapPort');
if (hostEl) hostEl.value = '';
if (portEl) portEl.value = '993';
});
document.getElementById('addAccountModal').classList.add('show');
}
// 隐藏添加账号模态框
function hideAddAccountModal() {
document.getElementById('addAccountModal').classList.remove('show');
}
// 添加账号
async function addAccount() {
const input = document.getElementById('accountInput').value.trim();
const groupId = parseInt(document.getElementById('importGroupSelect').value);
const providerEl = document.getElementById('accountProvider');
const provider = providerEl ? (providerEl.value || 'outlook') : 'outlook';
if (!input) {
showToast('请输入账号信息', 'error');
return;
}
try {
const payload = { account_string: input, group_id: groupId };
if (provider && provider !== 'outlook') {
payload.provider = provider;
if (provider === 'custom') {
const host = (document.getElementById('imapHost')?.value || '').trim();
const portRaw = (document.getElementById('imapPort')?.value || '').trim();
const port = parseInt(portRaw || '993', 10) || 993;
if (!host) {
// 允许每行内嵌 host/port:email----授权码----imap_host----imap_port(或导出格式 5 段)
const lines = input.split('\n').map(l => (l || '').trim()).filter(l => l && !l.startsWith('#'));
const hasInlineHost = lines.some(l => (l.split('----').length >= 4));
if (!hasInlineHost) {
showToast('请填写 IMAP 服务器地址(或在文本中每行包含 host/port)', 'error');
return;
}
} else {
payload.imap_host = host;
payload.imap_port = port;
}
}
}
const response = await fetch('/api/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
hideAddAccountModal();
// 清除该分组的缓存
delete accountsCache[groupId];
// 刷新分组列表(更新数量)
loadGroups();
// 如果当前选中的就是这个分组,刷新邮箱列表
if (currentGroupId === groupId) {
loadAccountsByGroup(groupId, true);
}
} else {
handleApiError(data, '导入邮箱失败');
}
} catch (error) {
showToast('添加失败', 'error');
}
}
// 显示编辑账号模态框
async function showEditAccountModal(accountId) {
try {
const response = await fetch(`/api/accounts/${accountId}`);
const data = await response.json();
if (data.success) {
const acc = data.account;
const isImap = (acc.account_type || 'outlook') === 'imap';
document.getElementById('editAccountId').value = acc.id;
document.getElementById('editAccountType').value = acc.account_type || 'outlook';
document.getElementById('editEmail').value = acc.email;
document.getElementById('editPassword').value = acc.password || '';
document.getElementById('editClientId').value = acc.client_id;
document.getElementById('editRefreshToken').value = acc.refresh_token;
document.getElementById('editGroupSelect').value = acc.group_id || 1;
document.getElementById('editRemark').value = acc.remark || '';
document.getElementById('editStatus').value = acc.status || 'active';
// IMAP 账号:隐藏 Client ID / Refresh Token,调整密码标签
const clientIdGroup = document.getElementById('editClientIdGroup');
const refreshTokenGroup = document.getElementById('editRefreshTokenGroup');
const passwordLabel = document.getElementById('editPasswordLabel');
if (isImap) {
clientIdGroup.style.display = 'none';
refreshTokenGroup.style.display = 'none';
passwordLabel.textContent = '授权码 / 应用密码';
document.getElementById('editPassword').placeholder = '留空则不修改';
} else {
clientIdGroup.style.display = '';
refreshTokenGroup.style.display = '';
passwordLabel.textContent = '密码';
document.getElementById('editPassword').placeholder = '可选,留空则不修改';
}
document.getElementById('editAccountModal').classList.add('show');
}
} catch (error) {
showToast('加载账号信息失败', 'error');
}
}
// 隐藏编辑账号模态框
function hideEditAccountModal() {
document.getElementById('editAccountModal').classList.remove('show');
}
// 更新账号
async function updateAccount() {
const accountId = document.getElementById('editAccountId').value;
const accountType = document.getElementById('editAccountType').value || 'outlook';
const isImap = accountType === 'imap';
const oldGroupId = currentGroupId;
const newGroupId = parseInt(document.getElementById('editGroupSelect').value);
const data = {
email: document.getElementById('editEmail').value.trim(),
password: document.getElementById('editPassword').value,
client_id: document.getElementById('editClientId').value.trim(),
refresh_token: document.getElementById('editRefreshToken').value.trim(),
group_id: newGroupId,
remark: document.getElementById('editRemark').value.trim(),
status: document.getElementById('editStatus').value
};
if (!data.email) {
showToast('邮箱地址不能为空', 'error');
return;
}
// Outlook 账号需要 Client ID 和 Refresh Token
if (!isImap && (!data.client_id || !data.refresh_token)) {
showToast('邮箱、Client ID 和 Refresh Token 不能为空', 'error');
return;
}
try {
const response = await fetch(`/api/accounts/${accountId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
showToast(result.message, 'success');
hideEditAccountModal();
// 清除相关分组的缓存
delete accountsCache[oldGroupId];
if (oldGroupId !== newGroupId) {
delete accountsCache[newGroupId];
}
// 刷新分组列表
loadGroups();
// 刷新当前分组的邮箱列表
if (currentGroupId) {
loadAccountsByGroup(currentGroupId, true);
}
} else {
showToast(result.error, 'error');
}
} catch (error) {
showToast('更新失败', 'error');
}
}
// 删除当前编辑的账号
async function deleteCurrentAccount() {
const accountId = document.getElementById('editAccountId').value;
const email = document.getElementById('editEmail').value;
const groupId = parseInt(document.getElementById('editGroupSelect').value);
if (!confirm(`确定要删除账号 ${email} 吗?`)) {
return;
}
try {
const response = await fetch(`/api/accounts/${accountId}`, { method: 'DELETE' });
const data = await response.json();
if (data.success) {
showToast('删除成功', 'success');
hideEditAccountModal();
// 清除缓存
delete accountsCache[groupId];
if (currentAccount === email) {
currentAccount = null;
document.getElementById('currentAccountBar').style.display = 'none';
document.getElementById('emailList').innerHTML = `
<div class="empty-state">
<span class="empty-icon">📬</span><p>请从左侧选择一个邮箱账号</p>
</div>
`;
document.getElementById('emailDetail').innerHTML = `
<div class="empty-state">
<span class="empty-icon">📄</span><p>选择一封邮件查看详情</p>
</div>
`;
}
// 刷新分组列表
loadGroups();
// 刷新当前分组的邮箱列表
if (currentGroupId) {
loadAccountsByGroup(currentGroupId, true);
}
}
} catch (error) {
showToast('删除失败', 'error');
}
}
// 切换账号状态(启用/停用)
async function toggleAccountStatus(accountId, currentStatus) {
const newStatus = currentStatus === 'inactive' ? 'active' : 'inactive';
const action = newStatus === 'inactive' ? '停用' : '启用';
try {
const response = await fetch(`/api/accounts/${accountId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
});
const data = await response.json();
if (data.success) {
showToast(`${action}成功`, 'success');
// 清除当前分组的缓存
if (currentGroupId) {
delete accountsCache[currentGroupId];
loadAccountsByGroup(currentGroupId, true);
}
} else {
handleApiError(data, `${action}账号失败`);
}
} catch (error) {
showToast(`${action}失败`, 'error');
}
}
// 删除账号(快捷方式)
async function deleteAccount(accountId, email) {
if (!confirm(`确定要删除账号 ${email} 吗?`)) {
return;
}
try {
const response = await fetch(`/api/accounts/${accountId}`, { method: 'DELETE' });
const data = await response.json();
if (data.success) {
showToast('删除成功', 'success');
// 清除当前分组的缓存
if (currentGroupId) {
delete accountsCache[currentGroupId];
}
if (currentAccount === email) {
currentAccount = null;
document.getElementById('currentAccountBar').style.display = 'none';
document.getElementById('emailList').innerHTML = `
<div class="empty-state">
<span class="empty-icon">📬</span><p>请从左侧选择一个邮箱账号</p>
</div>
`;
document.getElementById('emailDetail').innerHTML = `
<div class="empty-state">
<span class="empty-icon">📄</span><p>选择一封邮件查看详情</p>
</div>
`;
}
// 刷新分组列表
loadGroups();
// 刷新当前分组的邮箱列表
if (currentGroupId) {
loadAccountsByGroup(currentGroupId, true);
}
} else {
handleApiError(data, '删除账号失败');
}
} catch (error) {
showToast('删除失败', 'error');
}
}
// 显示导出邮箱模态框
async function showExportModal() {
document.getElementById('exportModal').classList.add('show');
await loadExportGroupList();
}
// 隐藏导出邮箱模态框
function hideExportModal() {
document.getElementById('exportModal').classList.remove('show');
}
// 加载导出分组列表
async function loadExportGroupList() {
const container = document.getElementById('exportGroupList');
container.innerHTML = '<div class="loading-overlay"><span class="spinner"></span></div>';
try {
if (groups.length === 0) {
container.innerHTML = '<div class="empty-state"><p>暂无分组</p></div>';
} else {
container.innerHTML = groups.map(group => `
<label style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; cursor: pointer; border-radius: var(--radius); transition: background-color 0.15s;"
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='transparent'">
<input type="checkbox" class="export-group-checkbox" value="${group.id}">
<span style="display: flex; align-items: center; gap: 8px; flex: 1;">
<span class="group-color-dot" style="background-color: ${group.color || '#666'}"></span>
<span style="font-size: 0.9rem; color: var(--text);">${escapeHtml(group.name)}</span>
</span>
<span class="badge-count">${group.account_count || 0}</span>
</label>
`).join('');
}
} catch (error) {
container.innerHTML = '<div class="empty-state"><p style="color:var(--clr-danger)">加载失败</p></div>';
}
document.getElementById('selectAllGroups').checked = false;
}
// 全选/取消全选分组
function toggleSelectAllGroups() {
const selectAll = document.getElementById('selectAllGroups').checked;
document.querySelectorAll('.export-group-checkbox').forEach(cb => {
cb.checked = selectAll;
});
}
// 存储待导出的分组ID
let pendingExportGroupIds = [];
// 导出选中的分组
async function exportSelectedGroups() {
const checkboxes = document.querySelectorAll('.export-group-checkbox:checked');
const groupIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
if (groupIds.length === 0) {
showToast('请选择要导出的分组', 'error');
return;
}
// 保存待导出的分组ID
pendingExportGroupIds = groupIds;
// 显示密码确认对话框
hideExportModal();
showExportVerifyModal();
}
// 显示导出密码确认对话框
function showExportVerifyModal() {
document.getElementById('exportVerifyModal').classList.add('show');
document.getElementById('exportVerifyPassword').value = '';
document.getElementById('exportVerifyPassword').focus();
}
// 隐藏导出密码确认对话框
function hideExportVerifyModal() {
document.getElementById('exportVerifyModal').classList.remove('show');
document.getElementById('exportVerifyPassword').value = '';
}
// 确认导出验证
async function confirmExportVerify() {
const password = document.getElementById('exportVerifyPassword').value;
if (!password) {
showToast('请输入密码', 'error');
return;
}
try {
// 获取验证token
const verifyResponse = await fetch('/api/export/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const verifyData = await verifyResponse.json();
if (!verifyData.success) {
showToast(verifyData.error || '密码错误', 'error');
return;
}
const verifyToken = verifyData.verify_token;
// 执行导出(使用请求头传递 token,避免 URL/日志泄露)
const response = await fetch('/api/accounts/export-selected', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Export-Token': verifyToken
},
body: JSON.stringify({
group_ids: pendingExportGroupIds
})
});
if (response.ok) {
// 获取文件名
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'accounts.txt';
if (contentDisposition) {
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?([^;\n]+)/i);
if (match) {
filename = decodeURIComponent(match[1]);
}
}
// 下载文件
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast('导出成功', 'success');
hideExportVerifyModal();
} else {
const data = await response.json();
handleApiError(data, '导出失败');
}
} catch (error) {
showToast('导出失败', 'error');
}
}