核心变更: - 数据库: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>
31 KiB
TDD-00005|多邮箱统一管理 — 技术设计细节文档
- 文档编号: TDD-00005
- 创建日期: 2026-03-02
- 版本: V1.0
- 状态: 草案
- 对齐 PRD:
docs/PRD/PRD-00005-多邮箱统一管理.md - 对齐 FD:
docs/FD/FD-00005-多邮箱统一管理.md
目录
- 文档目的
- 设计原则与硬约束
- 技术架构设计
- 文件变更清单
- DB 迁移设计(Schema v2 → v3)
- 新增服务层设计
- Repository 层改造
- Controller 层改造
- 前端改造设计
- 错误处理策略
- 安全设计
- 测试策略
1. 文档目的
本 TDD 描述"多邮箱统一管理"功能的完整技术实现细节。
重点回答:
- 如何在完全不改造现有 Outlook 流程的前提下,接入标准 IMAP 邮箱
- 如何通过
account_type字段实现按账号类型路由分发 - 如何对
accounts表进行幂等增量迁移(不破坏旧数据) - 新增的
imap_generic.py/providers.py的精确函数签名与行为 repositories/accounts.py、controllers/emails.py的最小改造点- 前端表单如何动态切换 Outlook / IMAP 两种模式
imap_password等敏感字段的加解密策略
2. 设计原则与硬约束
2.1 现有 Outlook 流程零改动
services/graph.py、services/imap.py(XOAUTH2):完全不动- Outlook 的 Graph → IMAP XOAUTH2 回退链:完全不动
- 现有 API 的 URL 与基本响应结构:完全不动
GET /api/emails/<email_addr>GET /api/emails/<email_addr>/extract-verificationPOST /api/emails/deleteGET /api/accounts、POST /api/accounts、GET /api/accounts/export、POST /api/accounts/export-selected
2.2 DB 约束处理
accounts.client_id/accounts.refresh_token为NOT NULL:不做 ALTER COLUMN- IMAP 账号入库时,上述字段使用占位空字符串
'' - 迁移只做
ADD COLUMN(幂等),不做DROP COLUMN/RENAME
2.3 IMAP 行为限制
- IMAP 账号不执行远程删除:delete 接口直接返回错误响应
- 定时刷新(scheduler)只处理 Outlook:查询必须过滤
account_type='outlook' OR account_type IS NULL - IMAP 连接统一使用
IMAP4_SSL(端口 993,SSL)
3. 技术架构设计
3.1 路由分发模型
用户请求(前端 or API)
↓
Controller 层
↓
读取 account 的 account_type
↓ ↓
'outlook' / NULL 'imap'
↓ ↓
【现有链路,完全不动】 【新增 imap_generic】
graph.py → imap.py(XOAUTH2) imap_generic.py
(fallback chain) (LOGIN 认证)
3.2 新增模块关系图
services/
providers.py ← 邮箱提供商常量(MAIL_PROVIDERS + PROVIDER_FOLDER_MAP)
imap_generic.py ← 通用 IMAP 服务(引用 providers.py)
repositories/
accounts.py ← 改造:支持 imap_password 加解密 + 新字段 CRUD
controllers/
accounts.py ← 改造:添加账号时解析 IMAP 格式 + providers API + 导出分类
emails.py ← 改造:按 account_type 路由(列表/验证码/删除)
db.py ← 改造:DB_SCHEMA_VERSION 3 + 新增 5 列迁移
4. 文件变更清单
| 文件 | 变更类型 | 变更内容概要 |
|---|---|---|
outlook_web/db.py |
改造 | DB_SCHEMA_VERSION=3,新增 5 列迁移,imap_password 加密迁移 |
outlook_web/services/providers.py |
新增 | MAIL_PROVIDERS 常量 + PROVIDER_FOLDER_MAP |
outlook_web/services/imap_generic.py |
新增 | get_emails_imap_generic() + get_email_detail_imap_generic() |
outlook_web/repositories/accounts.py |
改造 | load_accounts() 解密 imap_password;add_account() 支持新字段并加密 |
outlook_web/controllers/accounts.py |
改造 | 解析 IMAP 导入格式;新增 GET /api/providers;导出分类格式 |
outlook_web/controllers/emails.py |
改造 | 列表/验证码/删除按 account_type 路由 |
outlook_web/services/scheduler.py |
改造 | 定时任务查询加 account_type 过滤 |
templates/partials/modals.html |
改造 | 添加账号模态框增加 provider 下拉 + 动态表单 |
static/js/features/accounts.js |
改造 | addAccount() 请求体适配;账号列表 provider tag 展示 |
完全不动的文件:
services/graph.py、services/imap.py、services/refresh.py、security/、routes/、services/verification_extractor.py
5. DB 迁移设计(Schema v2 → v3)
5.1 版本常量更新
# outlook_web/db.py
DB_SCHEMA_VERSION = 3 # 原为 2
5.2 新增列的 DDL
在 init_db() 中,紧接现有幂等 ADD COLUMN 块之后,新增以下幂等迁移:
# --- Schema v3:多邮箱支持 ---
cursor.execute("PRAGMA table_info(accounts)")
columns = [col[1] for col in cursor.fetchall()]
if "account_type" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN account_type TEXT DEFAULT 'outlook'"
)
if "provider" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN provider TEXT DEFAULT 'outlook'"
)
if "imap_host" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN imap_host TEXT")
if "imap_port" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN imap_port INTEGER DEFAULT 993"
)
if "imap_password" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN imap_password TEXT")
注意:
PRAGMA table_info(accounts)已在前面的代码块中执行过一次(用于检测 group_id 等旧列)。为避免重复查询,应将上述检测合并到同一个列名列表变量中。具体做法:将现有代码中的cursor.execute("PRAGMA table_info(accounts)") columns = [col[1] for col in cursor.fetchall()]合并为一次查询,统一检测所有列(含旧列 + 新列)。
5.3 imap_password 加密迁移
在现有 migrate_sensitive_data() 中,补充对 imap_password 的迁移逻辑:
# 在 migrate_sensitive_data() 内,处理完 password/refresh_token 之后追加:
rows = conn.execute(
"SELECT id, imap_password FROM accounts WHERE imap_password IS NOT NULL AND imap_password != ''"
).fetchall()
for row in rows:
val = row["imap_password"]
if val and not is_encrypted(val):
encrypted = encrypt_data(val)
conn.execute(
"UPDATE accounts SET imap_password = ? WHERE id = ?",
(encrypted, row["id"]),
)
5.4 schema_version 写入
在迁移成功后的版本写入逻辑中(现有代码已有),DB_SCHEMA_VERSION=3 会自动被写入 settings 表的 db_schema_version,无需额外改动。
6. 新增服务层设计
6.1 outlook_web/services/providers.py
6.1.1 MAIL_PROVIDERS 常量
from typing import Any, Dict
MAIL_PROVIDERS: Dict[str, Dict[str, Any]] = {
"outlook": {
"label": "Outlook",
"imap_host": "outlook.live.com",
"imap_port": 993,
"account_type": "outlook",
"note": "使用 OAuth2 认证(client_id + refresh_token)",
},
"gmail": {
"label": "Gmail",
"imap_host": "imap.gmail.com",
"imap_port": 993,
"account_type": "imap",
"note": "需在 Google 账号中开启 IMAP,并使用应用专用密码(非登录密码)",
},
"qq": {
"label": "QQ 邮箱",
"imap_host": "imap.qq.com",
"imap_port": 993,
"account_type": "imap",
"note": "需在 QQ 邮箱设置中开启 IMAP 服务,使用授权码(非 QQ 密码)",
},
"163": {
"label": "163 邮箱",
"imap_host": "imap.163.com",
"imap_port": 993,
"account_type": "imap",
"note": "需在 163 邮箱设置中开启 IMAP 服务,使用授权码",
},
"126": {
"label": "126 邮箱",
"imap_host": "imap.126.com",
"imap_port": 993,
"account_type": "imap",
"note": "需在 126 邮箱设置中开启 IMAP 服务,使用授权码",
},
"yahoo": {
"label": "Yahoo 邮箱",
"imap_host": "imap.mail.yahoo.com",
"imap_port": 993,
"account_type": "imap",
"note": "需在 Yahoo 账号安全设置中生成应用密码",
},
"aliyun": {
"label": "阿里邮箱",
"imap_host": "imap.aliyun.com",
"imap_port": 993,
"account_type": "imap",
"note": "使用阿里邮箱登录密码",
},
"custom": {
"label": "自定义 IMAP",
"imap_host": "",
"imap_port": 993,
"account_type": "imap",
"note": "请手动填写 IMAP 服务器地址和端口",
},
}
6.1.2 PROVIDER_FOLDER_MAP 常量
PROVIDER_FOLDER_MAP: Dict[str, Dict[str, list]] = {
"gmail": {
"inbox": ["INBOX"],
"junkemail": ["[Gmail]/Spam", "[Gmail]/垃圾邮件"],
"deleteditems": ["[Gmail]/Trash", "[Gmail]/已删除邮件"],
},
"qq": {
"inbox": ["INBOX"],
"junkemail": ["Junk", "&V4NXPpCuTvY-"],
"deleteditems": ["Deleted Messages", "&XfJT0ZABkK5O9g-"],
},
"163": {
"inbox": ["INBOX"],
"junkemail": ["&V4NXPpCuTvY-"],
"deleteditems": ["&XfJT0ZABkK5O9g-"],
},
"yahoo": {
"inbox": ["INBOX"],
"junkemail": ["Bulk Mail"],
"deleteditems": ["Trash"],
},
"_default": {
"inbox": ["INBOX"],
"junkemail": ["Junk", "Spam", "SPAM", "Bulk Mail"],
"deleteditems": ["Trash", "Deleted", "Deleted Messages"],
},
}
6.1.3 辅助函数
def get_imap_folder_candidates(provider: str, folder: str) -> list:
"""
根据 provider 和逻辑文件夹名(inbox/junkemail/deleteditems),
返回候选 IMAP 文件夹名列表(按优先级排序)。
不存在的 provider 退回 _default。
"""
folder_map = PROVIDER_FOLDER_MAP.get(provider, PROVIDER_FOLDER_MAP["_default"])
return folder_map.get(folder, PROVIDER_FOLDER_MAP["_default"].get(folder, ["INBOX"]))
def get_provider_list() -> list:
"""返回供前端展示的 provider 列表(顺序固定:outlook 在前)"""
order = ["outlook", "gmail", "qq", "163", "126", "yahoo", "aliyun", "custom"]
result = []
for key in order:
if key in MAIL_PROVIDERS:
p = MAIL_PROVIDERS[key]
result.append({
"key": key,
"label": p["label"],
"account_type": p["account_type"],
"note": p.get("note", ""),
})
return result
6.2 outlook_web/services/imap_generic.py
6.2.1 模块职责
- 使用
imaplib.IMAP4_SSL建立连接(LOGIN 认证,不走 XOAUTH2) - 与现有
services/imap.py(Outlook XOAUTH2)完全独立并存 - 输出邮件结构对齐现有
get_emails_imap()/get_emails_graph()的 JSON 字段
6.2.2 内部辅助:IMAP 文件夹探测
def _resolve_imap_folder(
mail: imaplib.IMAP4_SSL,
candidates: list,
) -> Optional[str]:
"""
按顺序尝试 SELECT 候选文件夹名,返回第一个成功的文件夹名。
若全部失败,返回 None。
"""
for folder_name in candidates:
try:
status, _ = mail.select(folder_name, readonly=True)
if status == "OK":
return folder_name
except Exception:
continue
return None
6.2.3 邮件列表函数签名
def get_emails_imap_generic(
email_addr: str,
imap_password: str,
imap_host: str,
imap_port: int = 993,
folder: str = "inbox", # 逻辑文件夹:inbox / junkemail / deleteditems
provider: str = "_default", # 用于 PROVIDER_FOLDER_MAP 查找
skip: int = 0,
top: int = 20,
) -> Dict[str, Any]:
"""
标准 IMAP 邮件列表(LOGIN 认证)。
返回格式对齐现有 api_get_emails() 响应:
{
"success": True,
"emails": [...],
"method": "IMAP (Generic)",
"has_more": False
}
每封邮件字段:
id str IMAP UID(字符串)
subject str
from str
date str RFC2822 日期字符串
is_read bool
has_attachments bool
body_preview str 正文前 200 字符(纯文本)
错误时返回:
{
"success": False,
"error": "<脱敏消息>",
"error_code": "IMAP_AUTH_FAILED" | "IMAP_CONNECT_FAILED" | "IMAP_FOLDER_NOT_FOUND"
}
"""
6.2.4 邮件详情函数签名
def get_email_detail_imap_generic(
email_addr: str,
imap_password: str,
imap_host: str,
imap_port: int = 993,
message_id: str = "", # IMAP UID(字符串)
folder: str = "inbox",
provider: str = "_default",
) -> Optional[Dict[str, Any]]:
"""
标准 IMAP 邮件详情(按 UID fetch)。
返回字段:
id, subject, from, date, is_read, has_attachments,
body_text (str), body_html (str, 可为空)
失败时返回 None,并在 caller 侧由 controller 处理错误。
"""
6.2.5 实现要点
| 要点 | 具体实现 |
|---|---|
| 连接方式 | imaplib.IMAP4_SSL(imap_host, imap_port),超时设置 30s(socket.setdefaulttimeout) |
| 登录 | mail.login(email_addr, imap_password) |
| UID 使用 | mail.uid('SEARCH', ...) / mail.uid('FETCH', uid, '(FLAGS BODY[HEADER.FIELDS ...])') |
| 邮件排序 | 按 UID 倒序取最新 skip+top 封 |
| 分页 | 本期 has_more=False(不分页,与现有 IMAP XOAUTH2 回退保持一致) |
| is_read | 检查 FLAGS 中是否包含 \\Seen |
| body_preview | 优先取 text/plain part,截取前 200 字符;无 plain 则从 text/html strip 标签后截取 |
| 关闭 | finally 块中调用 mail.logout() |
7. Repository 层改造
文件:outlook_web/repositories/accounts.py
7.1 load_accounts() 改造
在现有解密 password 和 refresh_token 的代码块之后,追加 imap_password 解密:
# 在 load_accounts() 的 for row in rows 循环中追加:
if account.get("imap_password"):
try:
account["imap_password"] = decrypt_data(account["imap_password"])
except Exception:
pass # 解密失败不影响其他字段
返回的账号字典将包含新字段(若 DB 中存在):account_type、provider、imap_host、imap_port、imap_password(已解密)。
7.2 add_account() 改造
扩展函数签名,新增可选参数:
def add_account(
email_addr: str,
password: str,
client_id: str,
refresh_token: str,
group_id: int = None,
remark: str = None,
# --- 新增参数 ---
account_type: str = "outlook",
provider: str = "outlook",
imap_host: str = None,
imap_port: int = 993,
imap_password: str = None,
) -> int:
"""返回新插入的账号 id"""
改造点:
- 若
imap_password不为空,调用encrypt_data(imap_password)后再入库。 INSERT语句补充 5 个新字段的列名与值(使用?占位符)。account_type/provider/imap_host/imap_port直接入库(无加密需求)。
7.3 校验规则
在 add_account() 入参校验中,按 account_type 分支:
if account_type == "imap":
if not imap_password:
raise ValueError("IMAP 账号必须提供 imap_password")
if provider == "custom" and not imap_host:
raise ValueError("自定义 IMAP 账号必须提供 imap_host")
# client_id / refresh_token 为占位空字符串,不做非空校验
else: # outlook
if not client_id or not refresh_token:
raise ValueError("Outlook 账号必须提供 client_id 和 refresh_token")
8. Controller 层改造
8.1 controllers/accounts.py
8.1.1 POST /api/accounts:IMAP 导入解析
在现有 Outlook 解析逻辑(4 段格式)基础上,增加分支:
provider = request.json.get("provider", "outlook") if request.json else "outlook"
if provider == "outlook" or not provider:
# 现有逻辑不动:解析 account_string,4 段格式
...
else:
# IMAP 解析分支
account_string = request.json.get("account_string", "")
lines = [l.strip() for l in account_string.splitlines() if l.strip()]
provider_cfg = MAIL_PROVIDERS.get(provider, {})
imap_host = provider_cfg.get("imap_host", "")
imap_port = provider_cfg.get("imap_port", 993)
for line in lines:
if provider == "custom":
# 4 段:email----imap_password----imap_host----imap_port
parts = line.split("----")
if len(parts) < 2:
continue
email = parts[0].strip()
imap_pwd = parts[1].strip()
h = parts[2].strip() if len(parts) > 2 else ""
p = int(parts[3].strip()) if len(parts) > 3 else 993
else:
# 2 段:email----imap_password
parts = line.split("----")
if len(parts) < 2:
continue
email = parts[0].strip()
imap_pwd = parts[1].strip()
h = imap_host
p = imap_port
add_account(
email_addr=email,
password="",
client_id="",
refresh_token="",
group_id=group_id,
remark=remark,
account_type="imap",
provider=provider,
imap_host=h,
imap_port=p,
imap_password=imap_pwd,
)
8.1.2 GET /api/providers(新增路由)
@accounts_bp.route("/api/providers", methods=["GET"])
def api_get_providers():
"""返回邮箱提供商列表,用于前端下拉选择"""
from outlook_web.services.providers import get_provider_list
return jsonify({"success": True, "providers": get_provider_list()})
路由注册:在 outlook_web/routes/ 的 accounts 路由文件中追加该路由(或在 controllers/accounts.py 中直接注册,保持与现有风格一致)。
8.1.3 导出格式改造(GET /api/accounts/export + POST /api/accounts/export-selected)
现有导出逻辑生成纯文本,改造后按 Outlook/IMAP 分组:
def _build_export_text(accounts: list) -> str:
outlook_lines = []
imap_groups: Dict[str, list] = {} # provider -> [lines]
for acc in accounts:
atype = acc.get("account_type") or "outlook"
if atype == "outlook":
line = f"{acc['email']}----{acc.get('password','')}----{acc.get('client_id','')}----{acc.get('refresh_token','')}"
outlook_lines.append(line)
else:
provider = acc.get("provider", "custom")
imap_pwd = acc.get("imap_password", "")
if provider == "custom":
line = f"{acc['email']}----{imap_pwd}----{provider}----{acc.get('imap_host','')}----{acc.get('imap_port', 993)}"
else:
line = f"{acc['email']}----{imap_pwd}----{provider}"
imap_groups.setdefault(provider, []).append(line)
parts = []
if outlook_lines:
parts.append("# === Outlook 账号 ===")
parts.extend(outlook_lines)
for provider, lines in imap_groups.items():
label = MAIL_PROVIDERS.get(provider, {}).get("label", provider)
parts.append(f"\n# === IMAP 账号({label})===")
parts.extend(lines)
return "\n".join(parts)
现有导出接口的"二次验证"(export_verify_tokens 机制)保持不变。
8.2 controllers/emails.py
8.2.1 GET /api/emails/<email_addr>(邮件列表)
在获取账号信息后,加路由分发:
account = get_account_by_email(email_addr)
if not account:
return jsonify({"success": False, "error": "账号不存在"}), 404
account_type = account.get("account_type") or "outlook"
if account_type == "imap":
# --- 新增 IMAP 分支 ---
from outlook_web.services.imap_generic import get_emails_imap_generic
result = get_emails_imap_generic(
email_addr=email_addr,
imap_password=account.get("imap_password", ""),
imap_host=account.get("imap_host", ""),
imap_port=account.get("imap_port", 993),
folder=request.args.get("folder", "inbox"),
provider=account.get("provider", "_default"),
skip=int(request.args.get("skip", 0)),
top=int(request.args.get("top", 20)),
)
return jsonify(result)
else:
# 现有 Outlook 逻辑完全不动
...
8.2.2 GET /api/emails/<email_addr>/extract-verification(验证码提取)
account_type = account.get("account_type") or "outlook"
if account_type == "imap":
from outlook_web.services.imap_generic import (
get_emails_imap_generic,
get_email_detail_imap_generic,
)
from outlook_web.services.verification_extractor import extract_verification_info
# 取最新 1 封
emails_result = get_emails_imap_generic(
email_addr=email_addr,
imap_password=account.get("imap_password", ""),
imap_host=account.get("imap_host", ""),
imap_port=account.get("imap_port", 993),
folder="inbox",
provider=account.get("provider", "_default"),
skip=0,
top=1,
)
if not emails_result.get("success") or not emails_result.get("emails"):
return jsonify({"success": False, "error": "无法获取邮件"})
latest = emails_result["emails"][0]
detail = get_email_detail_imap_generic(
email_addr=email_addr,
imap_password=account.get("imap_password", ""),
imap_host=account.get("imap_host", ""),
imap_port=account.get("imap_port", 993),
message_id=latest["id"],
folder="inbox",
provider=account.get("provider", "_default"),
)
if not detail:
return jsonify({"success": False, "error": "无法获取邮件详情"})
verification = extract_verification_info(detail)
return jsonify({"success": True, **verification})
else:
# 现有 Outlook 逻辑完全不动
...
8.2.3 POST /api/emails/delete(删除)
account_type = account.get("account_type") or "outlook"
if account_type == "imap":
# 记录审计日志
log_audit(action="delete", resource_type="imap_email", resource_id=message_id)
return jsonify({
"success": False,
"error": "IMAP 邮箱不支持远程删除,请在邮箱客户端中操作"
})
else:
# 现有 Outlook 删除逻辑完全不动
...
8.3 services/scheduler.py
在 scheduled_refresh_task 内(第 268 行附近),将原查询:
"SELECT id, email, client_id, refresh_token, group_id FROM accounts WHERE status = 'active'"
改为:
"""
SELECT id, email, client_id, refresh_token, group_id
FROM accounts
WHERE status = 'active'
AND (account_type = 'outlook' OR account_type IS NULL)
"""
9. 前端改造设计
9.1 templates/partials/modals.html:添加账号模态框
9.1.1 新增 provider 下拉(在分组选择之前)
<!-- 邮箱类型选择(新增) -->
<div class="form-group">
<label for="accountProvider">邮箱类型</label>
<select id="accountProvider" class="form-select" onchange="onProviderChange(this.value)">
<!-- 由 JS 动态填充,来自 GET /api/providers -->
</select>
</div>
<!-- 自定义 IMAP 服务器配置(默认隐藏) -->
<div id="customImapFields" style="display:none;">
<div class="form-group">
<label for="imapHost">IMAP 服务器地址</label>
<input type="text" id="imapHost" class="form-input" placeholder="imap.example.com">
</div>
<div class="form-group">
<label for="imapPort">IMAP 端口</label>
<input type="number" id="imapPort" class="form-input" value="993">
</div>
</div>
<!-- 格式提示(动态更新) -->
<div id="accountFormatHint" class="form-hint"></div>
9.1.2 格式提示文案
| provider | placeholder 文本 | hint 文本 |
|---|---|---|
| outlook | email----password----client_id----refresh_token |
每行一个账号,4 段以 ---- 分隔 |
| qq/163/126 等 | user@qq.com----授权码 |
每行一个账号,使用授权码(非登录密码) |
| custom | user@domain.com----密码 |
需在上方填写 IMAP 服务器地址 |
9.2 static/js/features/accounts.js
9.2.1 页面初始化:加载 providers
async function loadProviders() {
const resp = await fetch('/api/providers');
const data = await resp.json();
const select = document.getElementById('accountProvider');
if (!select) return;
data.providers.forEach(p => {
const opt = document.createElement('option');
opt.value = p.key;
opt.textContent = p.label;
select.appendChild(opt);
});
onProviderChange(select.value); // 初始化提示文案
}
9.2.2 onProviderChange(provider) 函数
function onProviderChange(provider) {
const customFields = document.getElementById('customImapFields');
const hint = document.getElementById('accountFormatHint');
const textarea = document.getElementById('accountInput'); // 现有 textarea
if (provider === 'outlook') {
customFields.style.display = 'none';
textarea.placeholder = 'email----password----client_id----refresh_token';
hint.textContent = '每行一个账号,4 段以 ---- 分隔';
} else if (provider === 'custom') {
customFields.style.display = '';
textarea.placeholder = 'user@domain.com----密码';
hint.textContent = '需在上方填写 IMAP 服务器地址和端口';
} else {
customFields.style.display = 'none';
textarea.placeholder = 'user@example.com----授权码';
hint.textContent = '每行一个账号,使用授权码(非登录密码),以 ---- 分隔';
}
}
9.2.3 addAccount() 请求体适配
async function addAccount() {
const provider = document.getElementById('accountProvider')?.value || 'outlook';
const accountString = document.getElementById('accountInput').value.trim();
const groupId = document.getElementById('accountGroup').value;
let body;
if (provider === 'outlook') {
// 保持原格式,向后兼容
body = { account_string: accountString, group_id: groupId };
} else {
body = { provider, account_string: accountString, group_id: groupId };
if (provider === 'custom') {
// custom 字段在 account_string 中已包含,无需额外字段
}
}
const resp = await fetch('/api/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCSRFToken() },
body: JSON.stringify(body),
});
// 现有的响应处理逻辑不动
...
}
9.2.4 账号列表:provider tag 展示
在现有 renderAccountList() 函数中,为每个账号卡片追加 provider tag:
// 在账号卡片 HTML 模板中追加 tag(示例)
const providerLabel = getProviderLabel(account.provider || 'outlook');
const tagHtml = `<span class="account-provider-tag">${providerLabel}</span>`;
// 插入到邮箱地址后面
function getProviderLabel(provider) {
const labels = {
outlook: 'Outlook', gmail: 'Gmail', qq: 'QQ邮箱',
'163': '163邮箱', '126': '126邮箱', yahoo: 'Yahoo',
aliyun: '阿里邮箱', custom: '自定义',
};
return labels[provider] || provider;
}
确保 GET /api/accounts 返回的账号对象中包含 account_type 和 provider 字段(来自 load_accounts() 改造后自动包含)。
10. 错误处理策略
10.1 IMAP 错误码与场景
| 场景 | error_code | 用户提示(脱敏) |
|---|---|---|
| 认证失败(授权码错误/过期) | IMAP_AUTH_FAILED |
"IMAP 认证失败,请检查授权码是否正确" |
| 服务器不可达/端口错误 | IMAP_CONNECT_FAILED |
"IMAP 服务器连接失败,请检查网络或服务器配置" |
| 指定文件夹不存在 | IMAP_FOLDER_NOT_FOUND |
"邮箱文件夹不存在,请联系管理员" |
| SSL 握手失败 | IMAP_CONNECT_FAILED |
同上 |
| 其他未知错误 | IMAP_UNKNOWN_ERROR |
"邮件获取失败,请稍后重试" |
10.2 错误脱敏要求
在 imap_generic.py 中,所有 except 块必须:
- 不将
imap_password包含在日志或返回的 error 字符串中 - 使用
sanitize_error_details(str(e))处理异常信息(复用outlook_web/errors.py) - 对外返回结构统一为
{"success": False, "error": "<脱敏消息>", "error_code": "<CODE>"}
10.3 IMAP 错误不影响 Outlook 账号
由于路由分发在 controller 层,IMAP 错误只影响当前 IMAP 账号的请求,不会传播到其他 Outlook 账号的调用链。
11. 安全设计
11.1 imap_password 加解密
| 操作 | 函数 | 时机 |
|---|---|---|
| 入库前加密 | encrypt_data(imap_password) |
add_account() 中,在 INSERT 之前 |
| 读取后解密 | decrypt_data(account["imap_password"]) |
load_accounts() 中,在返回前 |
| 迁移加密 | encrypt_data() + is_encrypted() |
migrate_sensitive_data() 中补充逻辑 |
复用现有 outlook_web/security/crypto.py 中的 encrypt_data / decrypt_data / is_encrypted,不引入新的加密方案。
11.2 传输安全
- 所有 IMAP 连接使用
imaplib.IMAP4_SSL(TLS,端口 993) - 不使用
IMAP4(明文)
11.3 导出脱敏
导出文件包含明文 imap_password(已在 load_accounts() 解密),属于敏感操作。现有的"导出二次验证(export_verify_tokens)"机制覆盖此场景,不需要额外改动。
12. 测试策略
12.1 后端单元测试要点
| 测试项 | 验证内容 |
|---|---|
test_db_migration_v3 |
升级后 accounts 表包含 5 个新列;旧数据 account_type 默认为 outlook |
test_add_imap_account |
imap_password 入库为加密串;client_id/refresh_token 为 '' |
test_load_accounts_decrypts_imap_password |
读取时 imap_password 已解密 |
test_imap_generic_auth_failure |
认证失败时返回 IMAP_AUTH_FAILED,不暴露密码 |
test_imap_folder_resolution |
provider=qq,folder=inbox,返回候选列表 ["INBOX"] |
test_emails_api_routes_imap |
GET /api/emails/<imap_addr> 触发 imap_generic,不调用 graph.py |
test_emails_api_routes_outlook |
GET /api/emails/<outlook_addr> 走现有逻辑,不调用 imap_generic |
test_delete_imap_returns_unsupported |
IMAP 账号调用 delete 接口,返回 success=False + 错误消息 |
test_scheduler_skips_imap |
定时任务查询结果不包含 account_type='imap' 的账号 |
test_export_format_mixed |
混合 Outlook/QQ 导出,Outlook 在前,IMAP 按 provider 分组 |
12.2 P0 验收项(对应 FD 十一章)
- 旧 Outlook 账号:
GET /api/emails/<email>仍优先 Graph,失败后仍可 XOAUTH2 IMAP 回退 - 旧 Outlook 批量导入格式仍可用:
email----password----client_id----refresh_token - 新增 IMAP 账号可导入(QQ / 163 / Gmail 任一)并可拉取邮件列表和详情
- 新增 IMAP 账号可执行验证码提取(成功或返回"未找到")
- IMAP 账号调用删除接口返回"不支持远程删除",且无远程副作用
- 定时刷新任务不处理 IMAP 账号
imap_password在 DB 中为加密串(不以明文出现)
文档版本:V1.0 | 状态:草案