Files
outlookEmailPlus/outlook_web/repositories/accounts.py
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

352 lines
11 KiB
Python
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.
from __future__ import annotations
import sqlite3
from typing import Any, Dict, List, Optional
from outlook_web.db import get_db
from outlook_web.security.crypto import decrypt_data, encrypt_data
def load_accounts(group_id: int = None) -> List[Dict]:
"""从数据库加载邮箱账号(自动解密敏感字段,批量加载 tags 避免 N+1)"""
db = get_db()
if group_id:
cursor = db.execute(
"""
SELECT a.*, g.name as group_name, g.color as group_color
FROM accounts a
LEFT JOIN groups g ON a.group_id = g.id
WHERE a.group_id = ?
ORDER BY a.created_at DESC
""",
(group_id,),
)
else:
cursor = db.execute(
"""
SELECT a.*, g.name as group_name, g.color as group_color
FROM accounts a
LEFT JOIN groups g ON a.group_id = g.id
ORDER BY a.created_at DESC
"""
)
rows = cursor.fetchall()
tags_by_account: Dict[int, List[Dict[str, Any]]] = {}
try:
account_ids = [int(r["id"]) for r in rows]
except Exception:
account_ids = []
if account_ids:
try:
placeholders = ",".join(["?"] * len(account_ids))
tag_rows = db.execute(
f"""
SELECT at.account_id as account_id, t.*
FROM account_tags at
JOIN tags t ON t.id = at.tag_id
WHERE at.account_id IN ({placeholders})
ORDER BY at.account_id ASC, t.created_at DESC
""",
account_ids,
).fetchall()
for tr in tag_rows:
tag_dict = dict(tr)
acc_id = tag_dict.pop("account_id", None)
if acc_id is None:
continue
tags_by_account.setdefault(int(acc_id), []).append(tag_dict)
except Exception:
tags_by_account = {}
accounts: List[Dict[str, Any]] = []
for row in rows:
account = dict(row)
if account.get("password"):
try:
account["password"] = decrypt_data(account["password"])
except Exception:
pass
if account.get("refresh_token"):
try:
account["refresh_token"] = decrypt_data(account["refresh_token"])
except Exception:
pass
if account.get("imap_password"):
try:
account["imap_password"] = decrypt_data(account["imap_password"])
except Exception:
pass
account_id_value = account.get("id")
try:
account_id_value = int(account_id_value)
except Exception:
account_id_value = None
account["tags"] = (
tags_by_account.get(account_id_value, [])
if account_id_value is not None
else []
)
accounts.append(account)
return accounts
def get_account_by_email(email_addr: str) -> Optional[Dict]:
"""根据邮箱地址获取账号(自动解密敏感字段)"""
db = get_db()
cursor = db.execute("SELECT * FROM accounts WHERE email = ?", (email_addr,))
row = cursor.fetchone()
if not row:
return None
account = dict(row)
if account.get("password"):
try:
account["password"] = decrypt_data(account["password"])
except Exception:
pass
if account.get("refresh_token"):
try:
account["refresh_token"] = decrypt_data(account["refresh_token"])
except Exception:
pass
if account.get("imap_password"):
try:
account["imap_password"] = decrypt_data(account["imap_password"])
except Exception:
pass
return account
def get_account_by_id(account_id: int) -> Optional[Dict]:
"""根据 ID 获取账号(含 group_name/group_color,自动解密敏感字段)"""
db = get_db()
cursor = db.execute(
"""
SELECT a.*, g.name as group_name, g.color as group_color
FROM accounts a
LEFT JOIN groups g ON a.group_id = g.id
WHERE a.id = ?
""",
(account_id,),
)
row = cursor.fetchone()
if not row:
return None
account = dict(row)
if account.get("password"):
try:
account["password"] = decrypt_data(account["password"])
except Exception:
pass
if account.get("refresh_token"):
try:
account["refresh_token"] = decrypt_data(account["refresh_token"])
except Exception:
pass
if account.get("imap_password"):
try:
account["imap_password"] = decrypt_data(account["imap_password"])
except Exception:
pass
return account
def add_account(
email_addr: str,
password: str,
client_id: str,
refresh_token: str,
group_id: int = 1,
remark: str = "",
account_type: str = "outlook",
provider: str = "outlook",
imap_host: str = "",
imap_port: int = 993,
imap_password: str = "",
db: Optional[sqlite3.Connection] = None,
commit: bool = True,
) -> bool:
"""添加邮箱账号(支持外部事务批量导入)"""
db = db or get_db()
try:
account_type = (account_type or "outlook").strip().lower()
provider = (provider or ("outlook" if account_type != "imap" else "custom")).strip().lower()
# PRD-00005 / TDD-00005:
# - Outlook:必须提供 client_id/refresh_token(OAuth2)
# - IMAP:必须提供 imap_password;client_id/refresh_token 在 DB 中使用空字符串占位(保持 NOT NULL 约束)
if account_type == "imap":
if not (imap_password or "").strip():
return False
if provider == "custom" and not (imap_host or "").strip():
return False
else:
if not (client_id or "").strip() or not (refresh_token or "").strip():
return False
encrypted_password = encrypt_data(password) if password else password
encrypted_refresh_token = (
encrypt_data(refresh_token) if refresh_token else refresh_token
)
encrypted_imap_password = (
encrypt_data(imap_password) if imap_password else imap_password
)
db.execute(
"""
INSERT INTO accounts (
email, password, client_id, refresh_token,
account_type, provider, imap_host, imap_port, imap_password,
group_id, remark
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
email_addr,
encrypted_password,
client_id or "",
encrypted_refresh_token,
account_type,
provider,
imap_host or "",
int(imap_port) if imap_port else 993,
encrypted_imap_password,
group_id,
remark,
),
)
if commit:
db.commit()
return True
except sqlite3.IntegrityError:
return False
except Exception:
return False
def update_account(
account_id: int,
email_addr: str,
password: Optional[str],
client_id: Optional[str],
refresh_token: Optional[str],
group_id: int,
remark: str,
status: str,
) -> bool:
"""更新邮箱账号"""
db = get_db()
try:
existing = db.execute(
"""
SELECT password, client_id, refresh_token, account_type, imap_password
FROM accounts
WHERE id = ?
""",
(account_id,),
).fetchone()
if not existing:
return False
account_type = (existing["account_type"] or "outlook").strip().lower()
# PRD-00005 / TDD-00005:IMAP 账号不要求 client_id/refresh_token(DB 约束使用空字符串占位)
# 允许更新:email/group/remark/status;如用户在 UI 的“密码”栏输入内容,则视为更新 imap_password。
if account_type == "imap":
encrypted_imap_password = existing["imap_password"]
if isinstance(password, str) and password.strip():
encrypted_imap_password = encrypt_data(password)
if not email_addr:
return False
db.execute(
"""
UPDATE accounts
SET email = ?,
imap_password = ?,
group_id = ?,
remark = ?,
status = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(
email_addr,
encrypted_imap_password,
group_id,
remark,
status,
account_id,
),
)
db.commit()
return True
new_client_id = (
client_id.strip()
if isinstance(client_id, str) and client_id.strip()
else existing["client_id"]
)
encrypted_password = existing["password"]
if isinstance(password, str) and password.strip():
encrypted_password = encrypt_data(password)
encrypted_refresh_token = existing["refresh_token"]
if isinstance(refresh_token, str) and refresh_token.strip():
encrypted_refresh_token = encrypt_data(refresh_token)
if not email_addr or not new_client_id or not encrypted_refresh_token:
return False
db.execute(
"""
UPDATE accounts
SET email = ?, password = ?, client_id = ?, refresh_token = ?,
group_id = ?, remark = ?, status = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(
email_addr,
encrypted_password,
new_client_id,
encrypted_refresh_token,
group_id,
remark,
status,
account_id,
),
)
db.commit()
return True
except Exception:
return False
def delete_account_by_id(account_id: int) -> bool:
"""删除邮箱账号"""
db = get_db()
try:
db.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
db.commit()
return True
except Exception:
return False
def delete_account_by_email(email_addr: str) -> bool:
"""根据邮箱地址删除账号"""
db = get_db()
try:
db.execute("DELETE FROM accounts WHERE email = ?", (email_addr,))
db.commit()
return True
except Exception:
return False