Files
outlookEmailPlus/docs/TDD/TDD-00005-多邮箱统一管理.md
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

31 KiB
Raw Blame History

TDD-00005|多邮箱统一管理 — 技术设计细节文档

  • 文档编号: TDD-00005
  • 创建日期: 2026-03-02
  • 版本: V1.0
  • 状态: 草案
  • 对齐 PRD: docs/PRD/PRD-00005-多邮箱统一管理.md
  • 对齐 FD: docs/FD/FD-00005-多邮箱统一管理.md

目录

  1. 文档目的
  2. 设计原则与硬约束
  3. 技术架构设计
  4. 文件变更清单
  5. DB 迁移设计(Schema v2 → v3)
  6. 新增服务层设计
  7. Repository 层改造
  8. Controller 层改造
  9. 前端改造设计
  10. 错误处理策略
  11. 安全设计
  12. 测试策略

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-verification
    • POST /api/emails/delete
    • GET /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"""

改造点:

  1. 若 imap_password 不为空,调用 encrypt_data(imap_password) 后再入库。
  2. INSERT 语句补充 5 个新字段的列名与值(使用 ? 占位符)。
  3. 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 块必须:

  1. 不将 imap_password 包含在日志或返回的 error 字符串中
  2. 使用 sanitize_error_details(str(e)) 处理异常信息(复用 outlook_web/errors.py)
  3. 对外返回结构统一为 {"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 | 状态:草案