核心变更: - 数据库: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>
352 lines
11 KiB
Python
352 lines
11 KiB
Python
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
|