From 9673b3524de1bf49fbc724550269a892f1683562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E7=BB=8D=E5=BA=B7?= <3052147989@qq.com> Date: Mon, 9 Mar 2026 15:06:18 +0800 Subject: [PATCH] feat: add external api docs tests and verification updates --- .../FD-00008-对外验证码与邮件读取开放API.md | 967 +++++++++++++++ ...API-00008-对外验证码与邮件读取开放API.yaml | 940 ++++++++++++++ ...08-对外验证码与邮件读取开放API-执行日志.md | 120 ++ .../TDD-00008-对外验证码与邮件读取开放API.md | 1080 +++++++++++++++++ ...08-对外验证码与邮件读取开放API-测试文档.md | 766 ++++++++++++ docs/待办事项.md | 218 ++++ outlook_web/controllers/emails.py | 322 ++++- outlook_web/controllers/settings.py | 53 +- outlook_web/controllers/system.py | 90 +- outlook_web/db.py | 8 + outlook_web/repositories/settings.py | 30 + outlook_web/routes/emails.py | 37 + outlook_web/routes/system.py | 17 + outlook_web/security/auth.py | 61 + outlook_web/services/external_api.py | 568 +++++++++ outlook_web/services/imap.py | 23 +- outlook_web/services/imap_generic.py | 10 + .../services/verification_extractor.py | 220 ++++ static/js/main.js | 54 +- templates/index.html | 943 +++++++------- tests/test_external_api.py | 370 ++++++ tests/test_settings_external_api_key.py | 96 ++ tests/test_ui_settings_external_api_key.py | 40 + tests/test_verification_extractor_options.py | 74 ++ 24 files changed, 6617 insertions(+), 490 deletions(-) create mode 100644 docs/FD/FD-00008-对外验证码与邮件读取开放API.md create mode 100644 docs/FD/OPENAPI-00008-对外验证码与邮件读取开放API.yaml create mode 100644 docs/LOG/LOG-00008-对外验证码与邮件读取开放API-执行日志.md create mode 100644 docs/TDD/TDD-00008-对外验证码与邮件读取开放API.md create mode 100644 docs/TEST/TEST-00008-对外验证码与邮件读取开放API-测试文档.md create mode 100644 outlook_web/services/external_api.py create mode 100644 tests/test_external_api.py create mode 100644 tests/test_settings_external_api_key.py create mode 100644 tests/test_ui_settings_external_api_key.py create mode 100644 tests/test_verification_extractor_options.py diff --git a/docs/FD/FD-00008-对外验证码与邮件读取开放API.md b/docs/FD/FD-00008-对外验证码与邮件读取开放API.md new file mode 100644 index 0000000..05cc606 --- /dev/null +++ b/docs/FD/FD-00008-对外验证码与邮件读取开放API.md @@ -0,0 +1,967 @@ +# FD-00008|对外验证码与邮件读取开放 API — 功能实现设计文档 + +## 文档信息 + +- **文档编号**: FD-00008 +- **创建日期**: 2026-03-08 +- **版本**: V1.0 +- **状态**: 草案 +- **对齐 PRD**: `docs/PRD/PRD-00008-对外验证码与邮件读取开放API.md` +- **前置 FD**: `docs/FD/FD-00005-多邮箱统一管理.md` +- **覆盖范围**: 后端(Route + Controller + Service + Repository + Security)+ 前端(设置页 API Key 配置)+ 文档 + +--- + +## 一、功能概述 + +### 1.1 设计目标 + +基于现有模块化工程,为系统补齐一套**对外开放的验证码与邮件读取 API**,重点服务于**本地化部署下的自动化注册流程**。 + +本次设计目标如下: + +1. 在现有内部邮件读取能力之上,新增一层**API Key 鉴权的开放接口**。 +2. 对外直接暴露**验证码提取**与**验证链接提取**能力,降低接入方二次开发成本。 +3. 补齐**邮件列表 / 邮件详情 / RAW 内容**接口,支持人工排查与前端收件箱组件接入。 +4. 提供**健康检查 / 能力查询 / 账号状态检查**接口,用于本地部署后的自检和联调。 +5. 尽量复用现有 `controllers/emails.py`、`services/verification_extractor.py`、`services/graph.py`、`services/imap.py` 能力,避免重复实现。 + +### 1.2 核心约束 + +1. **不破坏现有内部 API**:`/api/emails/`、`/api/email//`、`/api/emails//extract-verification` 保持原有行为与登录态约束。 +2. **开放接口与后台登录态隔离**:外部接口不能依赖 Session/Cookie,只使用 `X-API-Key`。 +3. **优先复用现有读取链路**:Graph → IMAP(New) → IMAP(Old) 的回退策略保持一致。 +4. **不引入复杂多租户设计**:首版采用单实例单 `external_api_key` 模式。 +5. **敏感配置不明文回显**:设置页中仅展示脱敏后的开放 API Key 状态。 + +### 1.3 Out of Scope + +- Webhook/回调式外发通知 +- 多调用方 API Key 管理 +- 附件下载与附件元数据开放 +- 外部删除邮件、标记已读、移动邮件等写操作 +- 通用开放平台能力(限流配额、租户体系、开发者门户) + +--- + +## 二、现状分析与复用基础 + +### 2.1 现有可复用能力 + +当前根项目已经具备以下可复用模块: + +| 能力 | 文件 | 说明 | +|------|------|------| +| 内部邮件列表 | `outlook_web/controllers/emails.py` | `api_get_emails()` 已实现 Graph/IMAP 回退 | +| 内部邮件详情 | `outlook_web/controllers/emails.py` | `api_get_email_detail()` 已具备详情读取能力 | +| 内部验证码提取 | `outlook_web/controllers/emails.py` | `api_extract_verification()` 已有雏形 | +| 验证码/链接提取算法 | `outlook_web/services/verification_extractor.py` | 已支持验证码与链接解析 | +| Graph API 读取 | `outlook_web/services/graph.py` | 已用于 Outlook 账号读取 | +| IMAP 读取 | `outlook_web/services/imap.py` | 已支持 Outlook IMAP 回退 | +| 通用 IMAP 读取 | `outlook_web/services/imap_generic.py` | 已支持非 Outlook IMAP 账号 | +| 设置存取 | `outlook_web/repositories/settings.py` | 支持 key-value 配置落库 | +| 设置页 | `outlook_web/controllers/settings.py` + `templates/index.html` + `static/js/main.js` | 可扩展开放 API Key 配置 | +| 审计日志 | `outlook_web/audit.py` | 可复用记录开放接口访问 | +| 健康检查 | `outlook_web/controllers/system.py` | 已有 `/healthz` 与 `/api/system/health` | + +### 2.2 当前缺口 + +| 缺口 | 现状 | 需要补充 | +|------|------|----------| +| 外部鉴权 | 仅有 `login_required` | 新增 `api_key_required` | +| 外部接口路由 | 仅内部邮件 API | 新增 `/api/external/*` 路由 | +| 外部配置项 | 无 `external_api_key` | 设置表、设置接口、设置页均需扩展 | +| 统一响应结构 | 内外接口格式不完全一致 | 新增对外接口统一包装函数 | +| 账号状态检查 | 仅管理员健康页 | 新增轻量开放检查接口 | +| 健康接口外放 | `/api/system/health` 需登录 | 新增外部只读健康接口 | + +--- + +## 三、后端改造总览 + +### 3.1 变更文件清单 + +| 文件 | 变更类型 | 说明 | +|------|----------|------| +| `outlook_web/routes/emails.py` | **修改** | 注册新的 `/api/external/*` 路由 | +| `outlook_web/controllers/emails.py` | **修改** | 新增开放接口控制器函数 | +| `outlook_web/controllers/system.py` | **修改** | 新增开放健康检查与能力说明接口,或承载其 controller 逻辑 | +| `outlook_web/controllers/settings.py` | **修改** | 增加 `external_api_key` 的读取、保存、脱敏返回 | +| `outlook_web/repositories/settings.py` | **修改** | 新增 `get_external_api_key()` 等辅助函数 | +| `outlook_web/security/auth.py` | **修改** | 新增 `api_key_required` 装饰器 | +| `outlook_web/services/verification_extractor.py` | **修改** | 增强自定义规则、链接优先级、结果结构 | +| `outlook_web/services/external_api.py` | **新增** | 抽离开放 API 组装、筛选、统一响应逻辑 | +| `outlook_web/db.py` | **修改** | 初始化默认 settings key:`external_api_key` | +| `static/js/main.js` | **修改** | 设置页增加对外 API Key 字段的读取/保存 | +| `templates/index.html` | **修改** | 设置页新增“对外 API”配置区块 | +| `docs/api.md` 或对应文档 | **修改** | 补充开放接口使用方式(文档层,不在本 FD 详细展开) | +| `tests/test_external_api.py` | **新增** | 新增开放接口单元/契约测试 | + +### 3.2 不变更的文件 + +| 文件 | 原因 | +|------|------| +| `outlook_web/routes/accounts.py` | 本功能不依赖账号路由改造 | +| `outlook_web/controllers/accounts.py` | 账号导入导出逻辑不受影响 | +| `outlook_web/services/telegram_push.py` | 推送功能与本次开放接口解耦 | +| `outlook_web/security/csrf.py` | 对外 GET 接口无需新增 CSRF 逻辑 | + +--- + +## 四、路由设计 + +### 4.1 统一设计原则 + +- 开放接口统一挂在 `/api/external` 前缀下 +- 与现有内部接口语义对齐,但路径与鉴权方式分离 +- 首版以 `GET` 为主,避免复杂副作用 + +### 4.2 新增路由清单 + +**文件**:`outlook_web/routes/emails.py` + +建议在 `create_blueprint()` 中追加以下路由: + +```python +bp.add_url_rule( + "/api/external/messages", + view_func=emails_controller.api_external_get_messages, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/messages/latest", + view_func=emails_controller.api_external_get_latest_message, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/messages/", + view_func=emails_controller.api_external_get_message_detail, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/messages//raw", + view_func=emails_controller.api_external_get_message_raw, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/verification-code", + view_func=emails_controller.api_external_get_verification_code, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/verification-link", + view_func=emails_controller.api_external_get_verification_link, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/wait-message", + view_func=emails_controller.api_external_wait_message, + methods=["GET"], +) +``` + +**文件**:`outlook_web/routes/system.py` + +建议追加: + +```python +bp.add_url_rule( + "/api/external/health", + view_func=system_controller.api_external_health, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/capabilities", + view_func=system_controller.api_external_capabilities, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/account-status", + view_func=system_controller.api_external_account_status, + methods=["GET"], +) +``` + +--- + +## 五、安全与鉴权设计 + +### 5.1 `api_key_required` 装饰器 + +**文件**:`outlook_web/security/auth.py` + +新增装饰器: + +```python +def api_key_required(f): + """ + 对外开放 API 的 API Key 校验装饰器。 + + 规则: + 1. 仅接受 Header 中的 `X-API-Key` + 2. 未配置 external_api_key 时返回 403 + 3. 缺少或错误时返回 401 + 4. 不依赖 session,不触发登录跳转 + """ +``` + +### 5.2 鉴权细节 + +| 场景 | 行为 | +|------|------| +| Header 无 `X-API-Key` | 返回 `401 UNAUTHORIZED` | +| settings 中未配置 `external_api_key` | 返回 `403 API_KEY_NOT_CONFIGURED` | +| Key 不匹配 | 返回 `401 UNAUTHORIZED` | +| Key 匹配 | 放行到 controller | + +### 5.3 存储与展示策略 + +- `external_api_key` 存在 `settings` 表中 +- 为避免示例项目中的明文存储问题,根项目建议使用现有 `encrypt_data()` 加密后落库 +- 设置页返回时只暴露: + - `external_api_key_set: true/false` + - `external_api_key_masked: abcd****wxyz` +- 前端允许: + - 手动录入 + - 清空 + - 不回显明文 + +### 5.4 审计要求 + +所有开放接口请求都应记录审计日志: + +```python +log_audit( + action="external_api_access", + resource_type="external_api", + resource_id=email_addr, + details=json.dumps({...}, ensure_ascii=False), +) +``` + +建议记录字段: + +| 字段 | 说明 | +|------|------| +| `path` | 请求路径 | +| `email` | 查询邮箱 | +| `method` | 读取方式(Graph/IMAP) | +| `result` | success / failed | +| `error_code` | 失败错误码 | +| `trace_id` | 复用现有中间件 trace_id | + +--- + +## 六、设置与配置设计 + +### 6.1 Settings 表新增 Key + +**文件**:`outlook_web/db.py` + +复用现有 `settings` 表,不新增 schema。 + +新增初始化 key: + +| Key | 默认值 | 说明 | +|-----|--------|------| +| `external_api_key` | `""` | 对外开放接口 API Key(建议加密存储) | + +在 `init_db()` 中增加: + +```python +cursor.execute( + """ + INSERT OR IGNORE INTO settings (key, value) + VALUES ('external_api_key', '') + """ +) +``` + +### 6.2 `repositories/settings.py` 扩展 + +新增函数: + +```python +def get_external_api_key() -> str: + """获取对外 API Key,必要时自动解密。""" + + +def get_external_api_key_masked() -> str: + """返回脱敏展示值。""" +``` + +设计说明: + +- `get_setting()` / `set_setting()` 保持通用不变 +- `get_external_api_key()` 内部可识别 `is_encrypted()` 并自动解密 +- 避免在各 controller 中重复写解密逻辑 + +### 6.3 `controllers/settings.py` 扩展 + +#### 6.3.1 获取设置接口 + +在 `api_get_settings()` 中新增字段: + +```python +safe_settings["external_api_key_set"] = bool(external_api_key_value) +safe_settings["external_api_key_masked"] = mask_secret_value(external_api_key_plain) +``` + +#### 6.3.2 更新设置接口 + +在 `api_update_settings()` 中新增处理: + +```python +if "external_api_key" in data: + raw_value = str(data["external_api_key"]).strip() + if raw_value: + encrypted = encrypt_data(raw_value) + settings_repo.set_setting("external_api_key", encrypted) + else: + settings_repo.set_setting("external_api_key", "") +``` + +#### 6.3.3 设置页改造 + +**文件**:`templates/index.html` + +在系统设置页新增“对外 API”区块: + +- API Key 输入框 +- 已配置状态文案 +- 一键随机生成按钮(可选,前端本地生成) +- 保存按钮继续复用现有 `/api/settings` + +**文件**:`static/js/main.js` + +需要增加: + +1. `loadSettings()` 时读取 `external_api_key_masked` +2. `saveSettings()` 时提交 `external_api_key` +3. 随机生成功能(`crypto.getRandomValues` 或后端生成均可) + +--- + +## 七、Service 分层设计 + +### 7.1 新增 `outlook_web/services/external_api.py` + +新增服务模块,负责承接开放接口的公共逻辑,避免 `controllers/emails.py` 进一步膨胀。 + +#### 7.1.1 模块职责 + +- 统一读取请求参数 +- 复用内部读信逻辑并做二次筛选 +- 格式化开放接口返回结构 +- 封装统一错误码与统一响应体 +- 封装等待轮询逻辑 + +#### 7.1.2 推荐函数签名 + +```python +def list_messages_for_external( + *, + email_addr: str, + folder: str, + skip: int, + top: int, + from_contains: str = "", + subject_contains: str = "", + since_minutes: int | None = None, +) -> dict: + """获取对外邮件列表。""" + + +def get_latest_message_for_external(... ) -> dict: + """返回符合条件的最新一封邮件。""" + + +def get_message_detail_for_external(*, email_addr: str, message_id: str) -> dict: + """返回完整邮件详情。""" + + +def extract_verification_code_for_external(... ) -> dict: + """返回验证码提取结果。""" + + +def extract_verification_link_for_external(... ) -> dict: + """返回验证链接提取结果。""" + + +def wait_message_for_external(... ) -> dict: + """服务端轮询等待新邮件。""" +``` + +### 7.2 与现有服务的复用关系 + +| 目标能力 | 复用模块 | 说明 | +|----------|----------|------| +| 读取邮件列表 | `controllers/emails.py` 中现有读取逻辑下沉 / 复用 `graph_service` + `imap_service` | 本次建议抽出公共读取函数 | +| 获取详情 | `graph_service.get_email_detail_graph()` / `imap_service.get_email_detail_imap()` | 原能力保持 | +| 解析验证码/链接 | `services/verification_extractor.py` | 在此基础上增强自定义规则 | +| 健康检查 | `controllers/system.py` | 对外健康检查可做轻量版本封装 | + +### 7.3 建议的重构边界 + +为避免 controller 继续变大,建议在 `controllers/emails.py` 中抽出如下内部辅助函数到 `services/external_api.py`: + +```python +def _read_emails_with_fallback(account: dict, folder: str, skip: int, top: int) -> dict: + """统一封装 Graph -> IMAP(New) -> IMAP(Old) 读取链路。""" + + +def _read_email_detail_with_fallback(account: dict, message_id: str, folder: str = "inbox") -> dict: + """统一封装邮件详情读取链路。""" +``` + +### 7.4 `verification_extractor.py` 增强设计 + +当前 `verification_extractor.py` 已支持: + +- 关键词感知验证码提取 +- 保底正则提取 +- 链接提取 +- HTML → 纯文本转换 + +本次建议扩展以下能力: + +#### 7.4.1 新增自定义规则入口 + +```python +def extract_verification_info_with_options( + email: Dict[str, Any], + *, + code_regex: str | None = None, + code_length: str | None = None, + code_source: str = "all", + prefer_link_keywords: list[str] | None = None, +) -> Dict[str, Any]: + """ + 在现有提取器基础上支持: + - 自定义验证码正则 + - 自定义验证码长度范围 + - 提取来源限定(subject/content/html/all) + - 验证链接关键词优先级 + """ +``` + +#### 7.4.2 返回结构增强 + +现有返回: + +```python +{ + "verification_code": verification_code, + "links": links, + "formatted": formatted, +} +``` + +建议增强为: + +```python +{ + "verification_code": "123456", + "verification_link": "https://example.com/verify?...", + "links": [...], + "formatted": "123456 https://...", + "match_source": "content", + "confidence": "high", +} +``` + +#### 7.4.3 链接优先级策略 + +优先命中以下关键词的链接: + +```python +DEFAULT_LINK_KEYWORDS = [ + "verify", + "confirmation", + "confirm", + "activate", + "validation", +] +``` + +若未命中,再回退到首个 HTTP/HTTPS 链接。 + +--- + +## 八、Controller 设计 + +### 8.1 `controllers/emails.py` 新增开放接口函数 + +建议新增如下函数: + +```python +@api_key_required +def api_external_get_messages() -> Any: + ... + +@api_key_required +def api_external_get_latest_message() -> Any: + ... + +@api_key_required + +def api_external_get_message_detail(message_id: str) -> Any: + ... + +@api_key_required + +def api_external_get_message_raw(message_id: str) -> Any: + ... + +@api_key_required + +def api_external_get_verification_code() -> Any: + ... + +@api_key_required + +def api_external_get_verification_link() -> Any: + ... + +@api_key_required + +def api_external_wait_message() -> Any: + ... +``` + +### 8.2 参数解析规范 + +所有开放接口统一使用轻量参数解析函数: + +```python +def _parse_external_query_args() -> dict: + return { + "email": request.args.get("email", "").strip(), + "folder": request.args.get("folder", "inbox").strip().lower(), + "skip": int(request.args.get("skip", 0)), + "top": min(int(request.args.get("top", 20)), 50), + "from_contains": request.args.get("from_contains", "").strip(), + "subject_contains": request.args.get("subject_contains", "").strip(), + "since_minutes": ..., + } +``` + +### 8.3 统一响应包装 + +建议在 `services/external_api.py` 中提供: + +```python +def ok(data: Any, message: str = "success") -> dict: + return {"success": True, "code": "OK", "message": message, "data": data} + + +def fail(code: str, message: str, status: int, data: Any = None): + return jsonify({"success": False, "code": code, "message": message, "data": data}), status +``` + +这样可以避免开放接口继续沿用内部 `{"success": False, "error": payload}` 的复杂结构。 + +### 8.4 `api_external_get_messages` + +职责: + +1. 校验 `email`、`folder`、分页参数 +2. 查询账号是否存在 +3. 调用 `external_api.list_messages_for_external()` +4. 返回统一结构 +5. 记录审计日志 + +### 8.5 `api_external_get_latest_message` + +职责: + +- 基于 `messages` 列表结果取第一条,减少调用方二次处理 +- 当无命中邮件时返回 `MAIL_NOT_FOUND` + +### 8.6 `api_external_get_message_detail` + +职责: + +- 依赖 query 参数中的 `email` +- 读取指定邮件详情 +- 返回 text/html/raw 的聚合结构 + +说明: + +由于内部现有详情接口路径为 `/api/email//`,而开放接口拟采用 `/api/external/messages/?email=...`,因此开放接口需要通过 query 参数重新传入 `email` 以定位账号。 + +### 8.7 `api_external_get_message_raw` + +职责: + +- 调用详情接口能力 +- 仅返回 `raw_content` +- 用于前端调试或脚本兜底 + +### 8.8 `api_external_get_verification_code` + +职责: + +1. 获取符合条件的最新邮件 +2. 拉取完整详情 +3. 调用增强后的 `extract_verification_info_with_options()` +4. 若 `verification_code` 不存在,返回 `VERIFICATION_CODE_NOT_FOUND` + +### 8.9 `api_external_get_verification_link` + +职责与验证码类似,只是关注 `verification_link` 字段。 + +### 8.10 `api_external_wait_message` + +#### 8.10.1 行为定义 + +- 在单次请求生命周期内循环轮询 +- 到达超时时间仍未命中,则返回 `MAIL_NOT_FOUND` +- 默认参数: + - `timeout_seconds=30` + - `poll_interval=5` +- 最大超时时间限制为 120 秒,避免阻塞过久 + +#### 8.10.2 伪码 + +```python +start = time.time() +while time.time() - start < timeout_seconds: + result = get_latest_message_for_external(...) + if result["matched"]: + return ok(result) + time.sleep(poll_interval) +return fail("MAIL_NOT_FOUND", "等待超时,未检测到匹配邮件", 404) +``` + +--- + +## 九、System Controller 设计 + +### 9.1 `api_external_health` + +**文件**:`outlook_web/controllers/system.py` + +目标:提供不依赖登录态的开放健康检查,输出足够轻量,避免暴露内部过多状态。 + +建议返回: + +```json +{ + "success": true, + "code": "OK", + "message": "success", + "data": { + "status": "ok", + "service": "outlook-email-plus", + "server_time_utc": "2026-03-08T13:00:00Z", + "database": "ok" + } +} +``` + +与现有 `healthz()`、`api_system_health()` 的区别: + +| 接口 | 鉴权 | 目标 | +|------|------|------| +| `/healthz` | 无 | 容器探活 | +| `/api/system/health` | 登录态 | 管理员诊断 | +| `/api/external/health` | API Key | 外部接入自检 | + +### 9.2 `api_external_capabilities` + +返回当前实例支持能力列表,首版可写死并与代码实现同步: + +```python +FEATURES = [ + "message_list", + "message_detail", + "raw_content", + "verification_code", + "verification_link", + "wait_message", +] +``` + +### 9.3 `api_external_account_status` + +功能:检查指定邮箱是否存在、是否可以读取。 + +最简实现: + +1. 校验 `email` +2. 查询 `accounts_repo.get_account_by_email()` +3. 返回账号是否存在及基本状态 +4. **不在该接口中真正拉信**,避免副作用与慢响应 + +建议字段: + +```json +{ + "exists": true, + "account_type": "outlook", + "provider": "outlook", + "group_id": 1, + "last_refresh_at": "2026-03-08 20:00:00", + "preferred_method": "graph" +} +``` + +--- + +## 十、数据流设计 + +### 10.1 获取验证码流程 + +```text +客户端 + ↓ GET /api/external/verification-code +Route: outlook_web/routes/emails.py + ↓ +Controller: api_external_get_verification_code() + ↓ +Security: api_key_required + ↓ +Repository: accounts_repo.get_account_by_email() + ↓ +Service: external_api.get_latest_message_for_external() + ↓ +Service: _read_emails_with_fallback() + ├─ Graph API + ├─ IMAP New + └─ IMAP Old + ↓ +Service: _read_email_detail_with_fallback() + ↓ +Service: verification_extractor.extract_verification_info_with_options() + ↓ +Controller: ok()/fail() 统一包装 + ↓ +审计日志:log_audit("external_api_access", ...) + ↓ +返回 JSON +``` + +### 10.2 人工排查邮件详情流程 + +```text +客户端 + ↓ GET /api/external/messages/{message_id}?email=... +Controller + ↓ +账号校验 + ↓ +详情读取(Graph 优先,IMAP 回退) + ↓ +统一格式化 text/html/raw + ↓ +返回详情 JSON +``` + +--- + +## 十一、返回结构设计 + +### 11.1 成功结构 + +```json +{ + "success": true, + "code": "OK", + "message": "success", + "data": {} +} +``` + +### 11.2 失败结构 + +```json +{ + "success": false, + "code": "ACCOUNT_NOT_FOUND", + "message": "邮箱账号不存在", + "data": null +} +``` + +### 11.3 错误码映射 + +| 场景 | HTTP | code | +|------|------|------| +| 缺少 API Key | 401 | `UNAUTHORIZED` | +| 系统未配置 API Key | 403 | `API_KEY_NOT_CONFIGURED` | +| 缺少 email 参数 | 400 | `INVALID_PARAM` | +| folder 非法 | 400 | `INVALID_PARAM` | +| 账号不存在 | 404 | `ACCOUNT_NOT_FOUND` | +| 未找到匹配邮件 | 404 | `MAIL_NOT_FOUND` | +| 未提取到验证码 | 404 | `VERIFICATION_CODE_NOT_FOUND` | +| 未提取到验证链接 | 404 | `VERIFICATION_LINK_NOT_FOUND` | +| 代理异常 | 502 | `PROXY_ERROR` | +| 上游 Graph/IMAP 全失败 | 502 | `UPSTREAM_READ_FAILED` | +| 未知异常 | 500 | `INTERNAL_ERROR` | + +--- + +## 十二、前端与配置页设计 + +### 12.1 页面改造范围 + +**文件**:`templates/index.html` + +在“系统设置”页增加“对外 API”区块,建议字段: + +- `externalApiKey`:开放 API Key 输入框 +- `externalApiKeyStatus`:已配置状态文案 +- `generateExternalApiKeyBtn`:随机生成按钮(可选) + +### 12.2 JS 改造范围 + +**文件**:`static/js/main.js` + +需要改造: + +1. `loadSettings()`:读取 `external_api_key_masked` 与 `external_api_key_set` +2. `saveSettings()`:将 `external_api_key` 提交给 `/api/settings` +3. `generateExternalApiKey()`:前端生成随机值(长度建议 32-48) + +建议随机生成逻辑: + +```javascript +function generateExternalApiKey() { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); +} +``` + +### 12.3 交互说明 + +| 场景 | 行为 | +|------|------| +| 首次进入设置页 | 若未配置则显示空值 + 未配置提示 | +| 已配置 | 仅显示脱敏值 | +| 手动修改 | 保存时覆盖 | +| 清空 | 保存空字符串,表示禁用开放 API | + +--- + +## 十三、测试设计 + +### 13.1 新增测试文件 + +| 文件 | 说明 | +|------|------| +| `tests/test_external_api.py` | 开放接口核心契约测试 | +| `tests/test_settings_external_api_key.py` | 设置项读写与脱敏测试(可选) | +| `tests/test_verification_extractor_options.py` | 自定义提取规则测试(可选) | + +### 13.2 最小测试覆盖 + +#### 13.2.1 鉴权测试 + +- 未传 `X-API-Key` → 401 +- 未配置 `external_api_key` → 403 +- 错误 Key → 401 +- 正确 Key → 放行 + +#### 13.2.2 列表接口测试 + +- 账号不存在 → 404 +- Graph 成功 → 返回邮件列表 +- Graph 失败且 IMAP 成功 → 仍返回成功 +- folder 非法 → 400 + +#### 13.2.3 验证码接口测试 + +- 最新邮件命中验证码 → 返回 code +- 有邮件但无验证码 → `VERIFICATION_CODE_NOT_FOUND` +- 自定义 `code_length=6-6` 正常生效 +- 自定义 `code_regex` 正常生效 + +#### 13.2.4 验证链接接口测试 + +- 命中 `verify` 链接 → 返回高置信链接 +- 只有普通链接 → 返回首个有效链接 +- 无链接 → `VERIFICATION_LINK_NOT_FOUND` + +#### 13.2.5 健康与能力接口测试 + +- `health` 返回 `status=ok` +- `capabilities` 返回预定义能力列表 +- `account-status` 对存在/不存在账号分别返回正确结构 + +--- + +## 十四、实施顺序建议 + +### 14.1 第一阶段(后端基础) + +1. `repositories/settings.py` 增加 `external_api_key` 读取能力 +2. `security/auth.py` 增加 `api_key_required` +3. `db.py` 初始化默认 setting key +4. `services/external_api.py` 抽出公共逻辑 + +### 14.2 第二阶段(开放接口) + +1. 在 `routes/emails.py` 注册 `/api/external/messages*` +2. 在 `controllers/emails.py` 落开放接口函数 +3. 在 `routes/system.py` / `controllers/system.py` 落 `health/capabilities/account-status` +4. 接入统一错误码与审计日志 + +### 14.3 第三阶段(前端配置) + +1. `controllers/settings.py` 增加 API Key 配置读写 +2. `templates/index.html` 增加设置项 +3. `static/js/main.js` 对接设置页 + +### 14.4 第四阶段(测试与文档) + +1. 补开放接口测试 +2. 更新 API 文档与 README +3. 编写接入示例(curl / Python requests) + +--- + +## 十五、风险与决策记录 + +### 15.1 风险 + +| 风险 | 影响 | 缓解方式 | +|------|------|----------| +| `controllers/emails.py` 继续膨胀 | 可维护性下降 | 新增 `services/external_api.py` 承接逻辑 | +| API Key 明文泄露 | 安全风险 | 落库加密、设置页脱敏、日志不打印明文 | +| `wait-message` 阻塞请求线程 | 并发能力下降 | 限制最大 120 秒,默认 30 秒 | +| 外部接口与内部响应结构不一致 | 接入混乱 | 开放接口统一使用 `success/code/message/data` | +| 现有验证码提取误判 | 自动化稳定性不足 | 引入 `code_regex`、`code_length`、`code_source` 可配置项 | + +### 15.2 关键设计决策 + +1. **开放接口与内部接口分离,不直接复用原 URL**。 +2. **根项目的开放 API Key 使用加密存储**,不沿用示例项目的明文方案。 +3. **开放接口优先返回统一简洁结构**,不直接透出内部 `build_error_payload()` 的嵌套结构。 +4. **`account-status` 为轻量检查,不做真实拉信**,避免把自检接口做成慢接口。 +5. **`wait-message` 首版采用同步轮询实现**,不引入异步任务体系。 + +--- + +## 十六、验收映射(对应 PRD) + +| PRD 验收项 | FD 落地方式 | +|------------|-------------| +| 验证码读取 | `api_external_get_verification_code()` + extractor 增强 | +| 验证链接读取 | `api_external_get_verification_link()` | +| 邮件详情排查 | `messages` / `messages/{id}` / `raw` | +| 部署自检 | `health` / `capabilities` / `account-status` | +| 鉴权隔离 | `api_key_required` | +| Graph/IMAP 回退 | `_read_emails_with_fallback()` | + +--- + +## 十七、结论 + +本 FD 的核心思路是: + +- **不重做邮件读取内核**,而是在现有模块化工程上补一层开放 API; +- **把“验证码/链接提取”升级为一等能力**,而不是让调用方自己解析邮件正文; +- **同时补齐人工排查与部署自检接口**,让本地化部署后的接入、排障、联调都可闭环; +- **通过新服务层 `external_api.py` 控制复杂度**,避免继续把逻辑堆进 controller。 diff --git a/docs/FD/OPENAPI-00008-对外验证码与邮件读取开放API.yaml b/docs/FD/OPENAPI-00008-对外验证码与邮件读取开放API.yaml new file mode 100644 index 0000000..baf02e7 --- /dev/null +++ b/docs/FD/OPENAPI-00008-对外验证码与邮件读取开放API.yaml @@ -0,0 +1,940 @@ +openapi: 3.0.3 +info: + title: Outlook Email Plus External API + version: 0.1.0-draft + description: | + Outlook Email Plus 对外验证码与邮件读取开放 API 草稿。 + + 目标场景: + - 本地化部署后的自动化注册流程 + - 验证码与验证链接自动提取 + - 邮件详情排查与部署自检 + + 说明: + - 当前为草稿版本,接口与字段以 `docs/PRD/PRD-00008-对外验证码与邮件读取开放API.md` + 和 `docs/FD/FD-00008-对外验证码与邮件读取开放API.md` 为准。 + - 所有开放接口统一使用 `X-API-Key` 进行鉴权。 +servers: + - url: http://localhost:5000 + description: 本地开发/单机部署 + - url: https://your-domain.example.com + description: 反向代理/生产部署 +security: + - ApiKeyAuth: [] +tags: + - name: Messages + description: 邮件列表、详情与 RAW 内容查询 + - name: Verification + description: 验证码与验证链接提取 + - name: System + description: 外部健康检查、能力查询、账号状态检查 +paths: + /api/external/messages: + get: + tags: [Messages] + summary: 获取邮件列表 + description: 按邮箱地址和筛选条件获取最近邮件列表。 + operationId: listExternalMessages + parameters: + - $ref: '#/components/parameters/Email' + - $ref: '#/components/parameters/Folder' + - $ref: '#/components/parameters/Skip' + - $ref: '#/components/parameters/Top' + - $ref: '#/components/parameters/FromContains' + - $ref: '#/components/parameters/SubjectContains' + - $ref: '#/components/parameters/SinceMinutes' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/MessageListResponse' + '400': + $ref: '#/components/responses/InvalidParam' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ApiKeyNotConfigured' + '404': + $ref: '#/components/responses/AccountNotFound' + '502': + $ref: '#/components/responses/UpstreamReadFailed' + /api/external/messages/latest: + get: + tags: [Messages] + summary: 获取最新匹配邮件 + description: 返回符合筛选条件的最新一封邮件。 + operationId: getLatestExternalMessage + parameters: + - $ref: '#/components/parameters/Email' + - $ref: '#/components/parameters/Folder' + - $ref: '#/components/parameters/FromContains' + - $ref: '#/components/parameters/SubjectContains' + - $ref: '#/components/parameters/SinceMinutes' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/LatestMessageResponse' + '400': + $ref: '#/components/responses/InvalidParam' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ApiKeyNotConfigured' + '404': + $ref: '#/components/responses/MailNotFound' + '502': + $ref: '#/components/responses/UpstreamReadFailed' + /api/external/messages/{message_id}: + get: + tags: [Messages] + summary: 获取邮件详情 + description: 根据 message_id 与 email 获取单封邮件的详情内容。 + operationId: getExternalMessageDetail + parameters: + - $ref: '#/components/parameters/MessageIdPath' + - $ref: '#/components/parameters/Email' + - $ref: '#/components/parameters/FolderOptional' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/MessageDetailResponse' + '400': + $ref: '#/components/responses/InvalidParam' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ApiKeyNotConfigured' + '404': + $ref: '#/components/responses/MailNotFound' + '502': + $ref: '#/components/responses/UpstreamReadFailed' + /api/external/messages/{message_id}/raw: + get: + tags: [Messages] + summary: 获取邮件 RAW 内容 + description: 仅返回指定邮件的原始内容,便于排查编码和解析问题。 + operationId: getExternalMessageRaw + parameters: + - $ref: '#/components/parameters/MessageIdPath' + - $ref: '#/components/parameters/Email' + - $ref: '#/components/parameters/FolderOptional' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/MessageRawResponse' + '400': + $ref: '#/components/responses/InvalidParam' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ApiKeyNotConfigured' + '404': + $ref: '#/components/responses/MailNotFound' + '502': + $ref: '#/components/responses/UpstreamReadFailed' + /api/external/verification-code: + get: + tags: [Verification] + summary: 获取最新验证码 + description: 从符合条件的最新邮件中提取验证码。 + operationId: getExternalVerificationCode + parameters: + - $ref: '#/components/parameters/Email' + - $ref: '#/components/parameters/Folder' + - $ref: '#/components/parameters/FromContains' + - $ref: '#/components/parameters/SubjectContains' + - $ref: '#/components/parameters/SinceMinutes' + - $ref: '#/components/parameters/CodeLength' + - $ref: '#/components/parameters/CodeRegex' + - $ref: '#/components/parameters/CodeSource' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/VerificationCodeResponse' + '400': + $ref: '#/components/responses/InvalidParam' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ApiKeyNotConfigured' + '404': + description: 未找到匹配邮件或验证码 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + mailNotFound: + value: + success: false + code: MAIL_NOT_FOUND + message: 未找到匹配邮件 + data: null + verificationNotFound: + value: + success: false + code: VERIFICATION_CODE_NOT_FOUND + message: 未找到符合条件的验证码邮件 + data: null + '502': + $ref: '#/components/responses/UpstreamReadFailed' + /api/external/verification-link: + get: + tags: [Verification] + summary: 获取最新验证链接 + description: 从符合条件的最新邮件中提取首个高置信验证链接。 + operationId: getExternalVerificationLink + parameters: + - $ref: '#/components/parameters/Email' + - $ref: '#/components/parameters/Folder' + - $ref: '#/components/parameters/FromContains' + - $ref: '#/components/parameters/SubjectContains' + - $ref: '#/components/parameters/SinceMinutes' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/VerificationLinkResponse' + '400': + $ref: '#/components/responses/InvalidParam' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ApiKeyNotConfigured' + '404': + description: 未找到匹配邮件或验证链接 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + mailNotFound: + value: + success: false + code: MAIL_NOT_FOUND + message: 未找到匹配邮件 + data: null + linkNotFound: + value: + success: false + code: VERIFICATION_LINK_NOT_FOUND + message: 未找到符合条件的验证链接邮件 + data: null + '502': + $ref: '#/components/responses/UpstreamReadFailed' + /api/external/wait-message: + get: + tags: [Verification] + summary: 等待新邮件到达 + description: | + 在指定超时时间内轮询等待符合条件的新邮件到达。 + + 适用于自动化注册流程,减少客户端频繁轮询。 + operationId: waitExternalMessage + parameters: + - $ref: '#/components/parameters/Email' + - $ref: '#/components/parameters/Folder' + - $ref: '#/components/parameters/FromContains' + - $ref: '#/components/parameters/SubjectContains' + - $ref: '#/components/parameters/SinceMinutes' + - $ref: '#/components/parameters/TimeoutSeconds' + - $ref: '#/components/parameters/PollInterval' + responses: + '200': + description: 成功命中新邮件 + content: + application/json: + schema: + $ref: '#/components/schemas/LatestMessageResponse' + '400': + $ref: '#/components/responses/InvalidParam' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ApiKeyNotConfigured' + '404': + $ref: '#/components/responses/MailNotFound' + '502': + $ref: '#/components/responses/UpstreamReadFailed' + /api/external/health: + get: + tags: [System] + summary: 外部健康检查 + description: 用于本地部署后的接口连通性自检。 + operationId: externalHealth + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ApiKeyNotConfigured' + /api/external/capabilities: + get: + tags: [System] + summary: 查询开放能力 + description: 返回当前实例支持的对外开放能力列表。 + operationId: externalCapabilities + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/CapabilitiesResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ApiKeyNotConfigured' + /api/external/account-status: + get: + tags: [System] + summary: 查询邮箱账号状态 + description: 检查指定邮箱账号是否存在及基础可用状态。 + operationId: externalAccountStatus + parameters: + - $ref: '#/components/parameters/Email' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/AccountStatusResponse' + '400': + $ref: '#/components/responses/InvalidParam' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ApiKeyNotConfigured' + '404': + $ref: '#/components/responses/AccountNotFound' +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + parameters: + Email: + name: email + in: query + required: true + description: 目标邮箱地址 + schema: + type: string + format: email + example: user@outlook.com + Folder: + name: folder + in: query + required: false + description: 邮件文件夹 + schema: + type: string + enum: [inbox, junkemail, deleteditems] + default: inbox + FolderOptional: + name: folder + in: query + required: false + description: 邮件详情读取时的文件夹提示参数 + schema: + type: string + enum: [inbox, junkemail, deleteditems] + default: inbox + Skip: + name: skip + in: query + required: false + description: 分页偏移 + schema: + type: integer + minimum: 0 + default: 0 + Top: + name: top + in: query + required: false + description: 返回数量,最大建议 50 + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + FromContains: + name: from_contains + in: query + required: false + description: 发件人模糊匹配关键字 + schema: + type: string + SubjectContains: + name: subject_contains + in: query + required: false + description: 主题模糊匹配关键字 + schema: + type: string + SinceMinutes: + name: since_minutes + in: query + required: false + description: 最近 N 分钟内的邮件 + schema: + type: integer + minimum: 1 + default: 10 + MessageIdPath: + name: message_id + in: path + required: true + description: 邮件 ID + schema: + type: string + CodeLength: + name: code_length + in: query + required: false + description: 验证码长度范围,例如 `4-8` 或 `6-6` + schema: + type: string + pattern: '^\\d+-\\d+$' + example: 4-8 + CodeRegex: + name: code_regex + in: query + required: false + description: 自定义验证码正则表达式 + schema: + type: string + example: '\\b\\d{6}\\b' + CodeSource: + name: code_source + in: query + required: false + description: 验证码提取来源 + schema: + type: string + enum: [subject, content, html, all] + default: all + TimeoutSeconds: + name: timeout_seconds + in: query + required: false + description: 等待超时时间,最大建议 120 秒 + schema: + type: integer + minimum: 1 + maximum: 120 + default: 30 + PollInterval: + name: poll_interval + in: query + required: false + description: 轮询间隔秒数 + schema: + type: integer + minimum: 1 + maximum: 30 + default: 5 + schemas: + ErrorResponse: + type: object + required: [success, code, message, data] + properties: + success: + type: boolean + enum: [false] + code: + type: string + message: + type: string + data: + nullable: true + description: 失败时固定为 null + example: null + MessageSummary: + type: object + required: + - id + - email_address + - from_address + - subject + - has_html + - timestamp + - created_at + - is_read + properties: + id: + type: string + example: AAMkAGI2... + email_address: + type: string + format: email + example: user@outlook.com + from_address: + type: string + example: noreply@example.com + subject: + type: string + example: Your verification code + content_preview: + type: string + example: Your code is 123456 + has_html: + type: boolean + example: true + timestamp: + type: integer + format: int64 + example: 1772961600 + created_at: + type: string + format: date-time + example: '2026-03-08T12:00:00Z' + is_read: + type: boolean + example: false + method: + type: string + example: Graph API + MessageDetail: + type: object + required: + - id + - email_address + - from_address + - subject + - content + - html_content + - raw_content + - timestamp + - created_at + - has_html + properties: + id: + type: string + email_address: + type: string + format: email + from_address: + type: string + to_address: + type: string + example: receiver@example.com + subject: + type: string + content: + type: string + description: 纯文本正文 + html_content: + type: string + description: HTML 正文 + raw_content: + type: string + description: 原始邮件内容 + timestamp: + type: integer + format: int64 + created_at: + type: string + format: date-time + has_html: + type: boolean + method: + type: string + example: Graph API + MessageRaw: + type: object + required: [id, email_address, raw_content] + properties: + id: + type: string + email_address: + type: string + format: email + raw_content: + type: string + method: + type: string + example: IMAP (New) + VerificationCodeData: + type: object + required: + - email + - verification_code + - matched_email_id + - from + - subject + - received_at + properties: + email: + type: string + format: email + example: user@outlook.com + verification_code: + type: string + example: '123456' + matched_email_id: + type: string + example: AAMkAGI2... + from: + type: string + example: noreply@example.com + subject: + type: string + example: Your verification code + received_at: + type: string + format: date-time + example: '2026-03-08T12:00:00Z' + match_source: + type: string + enum: [subject, content, html, all] + example: content + confidence: + type: string + enum: [high, medium, low] + example: high + method: + type: string + example: Graph API + VerificationLinkData: + type: object + required: + - email + - verification_link + - matched_email_id + - from + - subject + - received_at + properties: + email: + type: string + format: email + verification_link: + type: string + format: uri + example: https://example.com/verify?token=abc123 + matched_email_id: + type: string + from: + type: string + subject: + type: string + received_at: + type: string + format: date-time + confidence: + type: string + enum: [high, medium, low] + example: high + method: + type: string + example: Graph API + HealthData: + type: object + required: [status, service, version, server_time_utc, database] + properties: + status: + type: string + example: ok + service: + type: string + example: outlook-email-plus + version: + type: string + example: 0.1.0-draft + server_time_utc: + type: string + format: date-time + example: '2026-03-08T12:00:00Z' + database: + type: string + example: ok + CapabilitiesData: + type: object + required: [service, version, features] + properties: + service: + type: string + example: outlook-email-plus + version: + type: string + example: 0.1.0-draft + features: + type: array + items: + type: string + example: + - message_list + - message_detail + - raw_content + - verification_code + - verification_link + - wait_message + AccountStatusData: + type: object + required: + - email + - exists + properties: + email: + type: string + format: email + exists: + type: boolean + account_type: + type: string + example: outlook + provider: + type: string + example: outlook + group_id: + type: integer + example: 1 + last_refresh_at: + type: string + nullable: true + example: '2026-03-08 20:00:00' + preferred_method: + type: string + example: graph + can_read: + type: boolean + example: true + MessageListResponse: + type: object + required: [success, code, message, data] + properties: + success: + type: boolean + enum: [true] + code: + type: string + example: OK + message: + type: string + example: success + data: + type: object + required: [emails, count] + properties: + emails: + type: array + items: + $ref: '#/components/schemas/MessageSummary' + count: + type: integer + example: 2 + has_more: + type: boolean + example: false + LatestMessageResponse: + type: object + required: [success, code, message, data] + properties: + success: + type: boolean + enum: [true] + code: + type: string + example: OK + message: + type: string + example: success + data: + $ref: '#/components/schemas/MessageSummary' + MessageDetailResponse: + type: object + required: [success, code, message, data] + properties: + success: + type: boolean + enum: [true] + code: + type: string + example: OK + message: + type: string + example: success + data: + $ref: '#/components/schemas/MessageDetail' + MessageRawResponse: + type: object + required: [success, code, message, data] + properties: + success: + type: boolean + enum: [true] + code: + type: string + example: OK + message: + type: string + example: success + data: + $ref: '#/components/schemas/MessageRaw' + VerificationCodeResponse: + type: object + required: [success, code, message, data] + properties: + success: + type: boolean + enum: [true] + code: + type: string + example: OK + message: + type: string + example: success + data: + $ref: '#/components/schemas/VerificationCodeData' + VerificationLinkResponse: + type: object + required: [success, code, message, data] + properties: + success: + type: boolean + enum: [true] + code: + type: string + example: OK + message: + type: string + example: success + data: + $ref: '#/components/schemas/VerificationLinkData' + HealthResponse: + type: object + required: [success, code, message, data] + properties: + success: + type: boolean + enum: [true] + code: + type: string + example: OK + message: + type: string + example: success + data: + $ref: '#/components/schemas/HealthData' + CapabilitiesResponse: + type: object + required: [success, code, message, data] + properties: + success: + type: boolean + enum: [true] + code: + type: string + example: OK + message: + type: string + example: success + data: + $ref: '#/components/schemas/CapabilitiesData' + AccountStatusResponse: + type: object + required: [success, code, message, data] + properties: + success: + type: boolean + enum: [true] + code: + type: string + example: OK + message: + type: string + example: success + data: + $ref: '#/components/schemas/AccountStatusData' + responses: + Unauthorized: + description: API Key 缺失或无效 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + success: false + code: UNAUTHORIZED + message: API Key 缺失或无效 + data: null + ApiKeyNotConfigured: + description: 系统未配置对外 API Key + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + success: false + code: API_KEY_NOT_CONFIGURED + message: 系统未配置对外 API Key + data: null + InvalidParam: + description: 参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + success: false + code: INVALID_PARAM + message: 参数错误 + data: null + AccountNotFound: + description: 指定邮箱账号不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + success: false + code: ACCOUNT_NOT_FOUND + message: 邮箱账号不存在 + data: null + MailNotFound: + description: 未找到匹配邮件 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + success: false + code: MAIL_NOT_FOUND + message: 未找到匹配邮件 + data: null + UpstreamReadFailed: + description: Graph / IMAP 均读取失败 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + success: false + code: UPSTREAM_READ_FAILED + message: Graph / IMAP 均读取失败 + data: null diff --git a/docs/LOG/LOG-00008-对外验证码与邮件读取开放API-执行日志.md b/docs/LOG/LOG-00008-对外验证码与邮件读取开放API-执行日志.md new file mode 100644 index 0000000..97c5ee5 --- /dev/null +++ b/docs/LOG/LOG-00008-对外验证码与邮件读取开放API-执行日志.md @@ -0,0 +1,120 @@ +# LOG-00008|对外验证码与邮件读取开放 API — 执行日志 + +## 目标 + +- 在不破坏现有内部接口的前提下,实现 `/api/external/*` 开放接口(仅 `X-API-Key` 鉴权) +- 按既有测试用例驱动落地实现,并通过全量回归测试 + +## 执行记录(2026-03-08) + +### 1) Settings 接入 external_api_key + API Key 鉴权 + +- 新增 settings 仓库能力: + - `get_external_api_key()`:读取并自动解密(兼容明文历史数据) + - `get_external_api_key_masked()`:脱敏展示 +- 扩展 `GET /api/settings` 返回: + - `external_api_key_set` + - `external_api_key_masked` +- 扩展 `PUT /api/settings` 支持保存/清空 `external_api_key`(加密存储,不回显明文) +- 新增 `api_key_required()`: + - Header 仅接受 `X-API-Key` + - 未配置 `external_api_key` → `403 API_KEY_NOT_CONFIGURED` + - 缺失/错误 → `401 UNAUTHORIZED` +- 数据库初始化补齐默认 setting:`external_api_key=""`(幂等) + +验证: +- `python -m unittest tests.test_settings_external_api_key -v` ✅ + +### 2) 参数化验证码/验证链接提取器 + +- 在 `verification_extractor.py` 新增 `extract_verification_info_with_options()`: + - `code_regex`:自定义正则优先 + - `code_length`:如 `6-6`(仅数字验证码) + - `code_source`:`subject/content/html/all` + - 验证链接优先关键词:`verify/confirm/activate/validation` +- 返回增强字段: + - `verification_link` + - `match_source` + - `confidence` + +验证: +- `python -m unittest tests.test_verification_extractor_options -v` ✅ + +### 3) 开放 API(/api/external/*)落地 + +- 新增 `outlook_web/services/external_api.py` 作为开放接口编排层: + - `ok()/fail()` 统一响应结构 + - 自定义异常统一映射错误码与 HTTP 状态码 + - Graph → IMAP(New) → IMAP(Old) 回退(列表/详情) + - `wait-message` 轮询实现(最大超时 120 秒,sleep 可被 mock) + - 访问审计:写入 `audit_logs(resource_type="external_api")` +- 注册路由: + - `outlook_web/routes/system.py`:`/api/external/health|capabilities|account-status` + - `outlook_web/routes/emails.py`:`/api/external/messages|messages/latest|messages/{id}|raw|verification-code|verification-link|wait-message` +- 控制器实现: + - `outlook_web/controllers/system.py` + - `outlook_web/controllers/emails.py` +- 补齐 RAW 内容: + - Outlook OAuth IMAP(XOAUTH2)详情返回 `raw_content` + - 标准 IMAP(Generic) 详情返回 `raw_content` + +验证: +- `python -m unittest tests.test_external_api -v` ✅ + +### 4) 全量回归 + +验证: +- `python -m unittest discover -s tests -v` ✅(253 tests) + +--- + +## 执行记录(2026-03-09) + +### 5) 对齐 OpenAPI/PRD:System 接口返回字段补齐 + +- `GET /api/external/capabilities` + - 补齐 `version` 字段(与 OpenAPI `CapabilitiesData.required=[service, version, features]` 对齐) +- `GET /api/external/account-status` + - 补齐 `account_type` / `provider` / `can_read` + - 增强 `email` 参数校验:为空或不包含 `@` → `400 INVALID_PARAM` +- 补齐 OpenAPI 文档:`HealthData` 增加 `version`(与 PRD “健康检查返回版本号” 对齐) + +验证: +- `python -m unittest tests.test_external_api.ExternalApiSystemTests -v` ✅ +- `python -m unittest tests.test_external_api -v` ✅ +- `python -m unittest tests.test_settings_external_api_key -v` ✅ +- `python -m unittest tests.test_verification_extractor_options -v` ✅ +- `python -m unittest discover -s tests -v` ✅(253 tests) + +### 6) 前端设置页集成 external_api_key(UI 配置闭环) + +- `templates/index.html` + - 设置页新增“对外开放 API Key”输入框(不回显明文,仅用于写入/清空) +- `static/js/main.js` + - `loadSettings()` 使用 `*_api_key_masked` 回填脱敏值,并记录 `dataset.maskedValue/isSet` + - `saveSettings()` 避免把脱敏占位符写回 DB;外部 API Key 支持清空禁用 +- `outlook_web/controllers/settings.py` + - `PUT /api/settings` 对 `gptmail_api_key` / `external_api_key` 增加“脱敏占位符不覆盖”保护 + - `gptmail_api_key` 支持清空(与前端行为一致) + +验证: +- `python -m unittest discover -s tests -v` ✅(256 tests) + +### 7) 手工冒烟测试(启动服务验证端到端链路) + +启动方式(禁用调度器自启 + 使用临时 DB,避免污染本地 `data/`): + +```powershell +$env:SECRET_KEY="test-secret-key-32bytes-minimum-0000000000000000" +$env:LOGIN_PASSWORD="testpass123" +$env:SCHEDULER_AUTOSTART="false" +$env:DATABASE_PATH=(Join-Path $env:TEMP "outlookEmail-manual-smoke.db") +python -m flask --app web_outlook_app run --host 127.0.0.1 --port 5055 +``` + +验证步骤(均通过): +- 登录:`POST /login` → 200 +- 获取 CSRF:`GET /api/csrf-token` → 200 +- 写入对外 API Key:`PUT /api/settings {"external_api_key":"abc123"}` → 200 +- 读取设置脱敏:`GET /api/settings` → `external_api_key_set=true` 且 `external_api_key_masked` 非明文 +- 外部健康检查:`GET /api/external/health`(Header `X-API-Key: abc123`)→ 200 `code=OK` diff --git a/docs/TDD/TDD-00008-对外验证码与邮件读取开放API.md b/docs/TDD/TDD-00008-对外验证码与邮件读取开放API.md new file mode 100644 index 0000000..c0ad5b5 --- /dev/null +++ b/docs/TDD/TDD-00008-对外验证码与邮件读取开放API.md @@ -0,0 +1,1080 @@ +# TDD-00008|对外验证码与邮件读取开放 API — 技术设计细节文档 + +- **文档编号**: TDD-00008 +- **创建日期**: 2026-03-08 +- **版本**: V1.0 +- **状态**: 草案 +- **对齐 PRD**: `docs/PRD/PRD-00008-对外验证码与邮件读取开放API.md` +- **对齐 FD**: `docs/FD/FD-00008-对外验证码与邮件读取开放API.md` +- **对齐 OpenAPI**: `docs/FD/OPENAPI-00008-对外验证码与邮件读取开放API.yaml` +- **前置依赖**: `docs/TDD/TDD-00005-多邮箱统一管理.md`(账号结构、Graph/IMAP 链路、模块化 Blueprint 架构) + +--- + +## 目录 + +1. [文档目的](#1-文档目的) +2. [设计原则与硬约束](#2-设计原则与硬约束) +3. [总体技术架构与数据流](#3-总体技术架构与数据流) +4. [文件变更清单](#4-文件变更清单) +5. [设置与鉴权技术细节](#5-设置与鉴权技术细节) +6. [Route 层技术细节](#6-route-层技术细节) +7. [Controller 层技术细节](#7-controller-层技术细节) +8. [External Service 层技术细节](#8-external-service-层技术细节) +9. [邮件读取与回退链路细节](#9-邮件读取与回退链路细节) +10. [验证码与验证链接提取细节](#10-验证码与验证链接提取细节) +11. [统一响应与错误码映射](#11-统一响应与错误码映射) +12. [系统自检接口实现细节](#12-系统自检接口实现细节) +13. [前端设置页改造细节](#13-前端设置页改造细节) +14. [兼容性与回归保障](#14-兼容性与回归保障) +15. [测试策略与测试用例](#15-测试策略与测试用例) +16. [实施顺序建议](#16-实施顺序建议) + +--- + +## 1. 文档目的 + +本 TDD 描述 PRD-00008「对外验证码与邮件读取开放 API」的**完整技术实现细节**,重点回答: + +- 如何在不破坏现有内部接口的前提下,新增一套 `X-API-Key` 鉴权的开放接口 +- 如何把现有内部邮件读取能力抽象成可复用的开放 Service,而不是继续堆叠在 controller 中 +- 如何实现验证码与验证链接的**可配置提取规则**(`code_regex` / `code_length` / `code_source`) +- 如何保证开放接口、设置页、审计日志、健康检查三者闭环 +- 如何让 OpenAPI 草稿中的字段、错误码、返回结构与真实代码实现保持一致 + +--- + +## 2. 设计原则与硬约束 + +### 2.1 API 与模块边界硬约束 + +- **新增路由前缀固定**:所有开放接口统一以 `/api/external` 开头 +- **不修改现有内部接口 URL**: + - `GET /api/emails/` + - `GET /api/emails//extract-verification` + - `GET /api/email//` + 保持不变 +- **开放接口不依赖 Session**:只能走 `X-API-Key`,不能走 `login_required` +- **开放接口响应结构统一**:固定为 `success/code/message/data` +- **开放接口首版仅开放读能力**:不新增外部删除、移动、已读等写操作 + +### 2.2 数据与安全硬约束 + +- **不新增数据库表**:复用 `settings`、`accounts`、`groups`、`audit_logs` +- **`external_api_key` 建议加密存储**:使用现有 `encrypt_data()` / `decrypt_data()` +- **设置页不回显明文 API Key**:仅返回 `*_set`、`*_masked` +- **日志中不允许输出明文 API Key** +- **查询参数中不接受 `api_key`**:根项目不沿用示例项目的 query 传 key 方案 + +### 2.3 向后兼容原则 + +- `outlook_web/controllers/emails.py` 中现有内部 API 行为与响应结构不变 +- `outlook_web/controllers/settings.py` 中现有设置项行为不变,仅扩展开放 API Key 字段 +- Graph → IMAP(New) → IMAP(Old) 的读取回退顺序保持不变 +- 现有 `verification_extractor.py` 默认提取逻辑不破坏,仅在外部接口场景补充参数化入口 + +--- + +## 3. 总体技术架构与数据流 + +### 3.1 获取验证码主链路 + +```text +客户端 + ↓ GET /api/external/verification-code +Route: outlook_web/routes/emails.py + ↓ +Security: api_key_required + ↓ +Controller: api_external_get_verification_code() + ↓ +Repository: accounts_repo.get_account_by_email(email) + ↓ +Service: external_api.get_latest_message_for_external(...) + ↓ +Service: _read_emails_with_fallback(account, folder, skip=0, top=N) + ├─ graph_service.get_emails_graph(...) + ├─ imap_service.get_emails_imap_with_server(..., IMAP_SERVER_NEW) + └─ imap_service.get_emails_imap_with_server(..., IMAP_SERVER_OLD) + ↓ +Service: _read_email_detail_with_fallback(account, message_id, folder) + ↓ +Service: verification_extractor.extract_verification_info_with_options(...) + ↓ +Service: ok()/fail() 统一包装 + ↓ +Audit: log_audit("external_api_access", ...) + ↓ +返回 JSON +``` + +### 3.2 人工排查详情链路 + +```text +客户端 + ↓ GET /api/external/messages/{message_id}?email=... +api_key_required + ↓ +Controller 校验 email/message_id + ↓ +Service 获取 account + ↓ +Service 读取详情(Graph 优先,IMAP 回退) + ↓ +Service 组装 MessageDetail + ↓ +返回统一响应 +``` + +### 3.3 设置页配置链路 + +```text +前端 settings 页面 + ↓ GET /api/settings +controllers/settings.py + ↓ +repositories/settings.py + ↓ +返回 external_api_key_set / external_api_key_masked + +前端保存 settings + ↓ PUT /api/settings +controllers/settings.py + ↓ +encrypt_data(external_api_key) + ↓ +repositories/settings.py.set_setting('external_api_key', encrypted) +``` + +--- + +## 4. 文件变更清单 + +| 文件 | 变更类型 | 说明 | +|---|---|---| +| `outlook_web/security/auth.py` | **修改** | 新增 `api_key_required()` | +| `outlook_web/repositories/settings.py` | **修改** | 新增 `get_external_api_key()`、`get_external_api_key_masked()` | +| `outlook_web/controllers/settings.py` | **修改** | 扩展 `external_api_key` 读写与脱敏展示 | +| `outlook_web/db.py` | **修改** | 初始化默认 `external_api_key` setting | +| `outlook_web/routes/emails.py` | **修改** | 注册开放消息与验证接口 | +| `outlook_web/routes/system.py` | **修改** | 注册开放系统接口 | +| `outlook_web/controllers/emails.py` | **修改** | 新增开放 API 控制器函数 | +| `outlook_web/controllers/system.py` | **修改** | 新增开放健康检查/能力/账号状态函数 | +| `outlook_web/services/external_api.py` | **新增** | 外部接口专用 service 层 | +| `outlook_web/services/verification_extractor.py` | **修改** | 增强参数化提取入口 | +| `templates/index.html` | **修改** | 设置页增加开放 API Key 配置区块 | +| `static/js/main.js` | **修改** | 读取/保存开放 API Key | +| `tests/test_external_api.py` | **新增** | 契约/集成测试 | +| `tests/test_settings_external_api_key.py` | **新增/可选** | 设置项测试 | + +--- + +## 5. 设置与鉴权技术细节 + +### 5.1 `settings` 表 Key 设计 + +复用 `settings` 表,不新增 schema。新增 key: + +| Key | 默认值 | 存储方式 | 说明 | +|---|---|---|---| +| `external_api_key` | `""` | 建议加密 | 对外开放接口使用的 API Key | + +### 5.2 `db.py` 初始化逻辑 + +文件:`outlook_web/db.py` + +在 `init_db()` 的默认 settings 初始化阶段增加: + +```python +cursor.execute( + """ + INSERT OR IGNORE INTO settings (key, value) + VALUES ('external_api_key', '') + """ +) +``` + +说明: +- 不修改 `DB_SCHEMA_VERSION` +- 因为只是新增默认配置,不涉及 schema 变更 +- 对既有数据库幂等生效 + +### 5.3 `repositories/settings.py` 技术设计 + +新增函数: + +```python +def get_external_api_key() -> str: + """ + 获取对外 API Key。 + - 若值为空,返回空字符串 + - 若值使用 enc: 前缀加密,自动解密 + - 若值为历史明文(兼容老数据),直接返回明文 + """ + + +def get_external_api_key_masked() -> str: + """ + 返回脱敏展示值。 + 规则:前 4 位 + 若干 * + 后 4 位;长度不足时返回全 *。 + """ +``` + +建议实现: + +```python +from outlook_web.security.crypto import decrypt_data, is_encrypted + + +def get_external_api_key() -> str: + value = get_setting("external_api_key", "") + if not value: + return "" + if is_encrypted(value): + try: + return decrypt_data(value) + except Exception: + return "" + return value +``` + +### 5.4 `api_key_required()` 技术设计 + +文件:`outlook_web/security/auth.py` + +新增: + +```python +from outlook_web.repositories.settings import get_external_api_key + + +def api_key_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + api_key = (request.headers.get("X-API-Key") or "").strip() + if not api_key: + return jsonify({ + "success": False, + "code": "UNAUTHORIZED", + "message": "API Key 缺失或无效", + "data": None, + }), 401 + + stored_key = get_external_api_key() + if not stored_key: + return jsonify({ + "success": False, + "code": "API_KEY_NOT_CONFIGURED", + "message": "系统未配置对外 API Key", + "data": None, + }), 403 + + if api_key != stored_key: + return jsonify({ + "success": False, + "code": "UNAUTHORIZED", + "message": "API Key 缺失或无效", + "data": None, + }), 401 + + return f(*args, **kwargs) + return decorated_function +``` + +### 5.5 鉴权实现注意点 + +- 不复用 `build_error_payload()`,因为开放接口返回结构已在 OpenAPI 中固定为简化结构 +- 不跳转登录页面 +- 不读取 query 参数中的 `api_key` +- 所有开放接口 controller 必须直接使用 `@api_key_required` + +--- + +## 6. Route 层技术细节 + +### 6.1 `outlook_web/routes/emails.py` + +在现有内部路由后追加: + +```python +bp.add_url_rule( + "/api/external/messages", + view_func=emails_controller.api_external_get_messages, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/messages/latest", + view_func=emails_controller.api_external_get_latest_message, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/messages/", + view_func=emails_controller.api_external_get_message_detail, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/messages//raw", + view_func=emails_controller.api_external_get_message_raw, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/verification-code", + view_func=emails_controller.api_external_get_verification_code, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/verification-link", + view_func=emails_controller.api_external_get_verification_link, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/wait-message", + view_func=emails_controller.api_external_wait_message, + methods=["GET"], +) +``` + +说明: +- 统一放在 `emails` Blueprint 内,避免额外新建 Blueprint +- 与内部邮件能力聚合,减少初始化改动 + +### 6.2 `outlook_web/routes/system.py` + +追加: + +```python +bp.add_url_rule( + "/api/external/health", + view_func=system_controller.api_external_health, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/capabilities", + view_func=system_controller.api_external_capabilities, + methods=["GET"], +) +bp.add_url_rule( + "/api/external/account-status", + view_func=system_controller.api_external_account_status, + methods=["GET"], +) +``` + +--- + +## 7. Controller 层技术细节 + +### 7.1 `controllers/emails.py` 新增函数清单 + +```python +@api_key_required +def api_external_get_messages() -> Any: ... + +@api_key_required +def api_external_get_latest_message() -> Any: ... + +@api_key_required +def api_external_get_message_detail(message_id: str) -> Any: ... + +@api_key_required +def api_external_get_message_raw(message_id: str) -> Any: ... + +@api_key_required +def api_external_get_verification_code() -> Any: ... + +@api_key_required +def api_external_get_verification_link() -> Any: ... + +@api_key_required +def api_external_wait_message() -> Any: ... +``` + +### 7.2 Controller 只做三件事 + +1. 解析并校验参数 +2. 调用 `services/external_api.py` +3. 写审计日志并返回统一响应 + +### 7.3 参数解析辅助函数 + +建议在 `controllers/emails.py` 内新增: + +```python +def _parse_external_args() -> dict: + folder = (request.args.get("folder", "inbox") or "inbox").strip().lower() + if folder not in {"inbox", "junkemail", "deleteditems"}: + raise ValueError("folder 参数无效") + + top = int(request.args.get("top", 20)) + if top < 1 or top > 50: + raise ValueError("top 参数无效") + + skip = int(request.args.get("skip", 0)) + if skip < 0: + raise ValueError("skip 参数无效") + + return { + "email": (request.args.get("email", "") or "").strip(), + "folder": folder, + "skip": skip, + "top": top, + "from_contains": (request.args.get("from_contains", "") or "").strip(), + "subject_contains": (request.args.get("subject_contains", "") or "").strip(), + "since_minutes": request.args.get("since_minutes"), + "code_length": (request.args.get("code_length", "") or "").strip(), + "code_regex": (request.args.get("code_regex", "") or "").strip(), + "code_source": (request.args.get("code_source", "all") or "all").strip().lower(), + "timeout_seconds": request.args.get("timeout_seconds"), + "poll_interval": request.args.get("poll_interval"), + } +``` + +### 7.4 参数校验约束 + +| 参数 | 校验规则 | +|---|---| +| `email` | 必填,必须含 `@` | +| `folder` | `inbox/junkemail/deleteditems` | +| `top` | `1-50` | +| `skip` | `>=0` | +| `since_minutes` | `>=1` | +| `timeout_seconds` | `1-120` | +| `poll_interval` | `1-30` 且 `< timeout_seconds` | +| `code_source` | `subject/content/html/all` | +| `code_length` | 匹配 `^\d+-\d+$` | + +### 7.5 审计日志实现约定 + +每个开放接口在 controller 末尾统一记录: + +```python +log_audit( + action="external_api_access", + resource_type="external_api", + resource_id=email_addr, + details=json.dumps( + { + "path": request.path, + "result": "success", + "method": payload_method, + "error_code": "", + }, + ensure_ascii=False, + ), +) +``` + +失败分支同样记录 `result=failed` 与 `error_code`。 + +--- + +## 8. External Service 层技术细节 + +### 8.1 新增文件:`outlook_web/services/external_api.py` + +该模块负责承接开放接口通用逻辑,避免 `controllers/emails.py` 继续膨胀。 + +### 8.2 模块函数清单 + +```python +def ok(data, message: str = "success") -> dict: + return {"success": True, "code": "OK", "message": message, "data": data} + + +def fail(code: str, message: str, status: int, data=None): + return jsonify({"success": False, "code": code, "message": message, "data": data}), status + + +def list_messages_for_external(... ) -> dict: ... +def get_latest_message_for_external(... ) -> dict: ... +def get_message_detail_for_external(... ) -> dict: ... +def get_message_raw_for_external(... ) -> dict: ... +def extract_verification_code_for_external(... ) -> dict: ... +def extract_verification_link_for_external(... ) -> dict: ... +def wait_message_for_external(... ) -> dict: ... +``` + +### 8.3 `list_messages_for_external()` 设计 + +输入: +- `email_addr` +- `folder` +- `skip` +- `top` +- `from_contains` +- `subject_contains` +- `since_minutes` + +输出: + +```python +{ + "emails": [...], + "count": 2, + "has_more": False, +} +``` + +实现步骤: + +1. `accounts_repo.get_account_by_email(email_addr)` +2. `_read_emails_with_fallback(account, folder, skip, top)` +3. 将读取结果统一映射成 `MessageSummary` +4. 按 `from_contains`、`subject_contains`、`since_minutes` 在 service 层二次过滤 +5. 返回过滤后结果 + +### 8.4 `get_latest_message_for_external()` + +实现: +- 直接调用 `list_messages_for_external(..., skip=0, top=max(top, 20))` +- 若 `emails` 为空,抛 `MailNotFoundError` +- 返回 `emails[0]` + +### 8.5 `get_message_detail_for_external()` + +实现: +1. 获取账号 +2. 调用 `_read_email_detail_with_fallback()` +3. 统一映射为: + +```python +{ + "id": ..., + "email_address": email_addr, + "from_address": ..., + "to_address": ..., + "subject": ..., + "content": ..., + "html_content": ..., + "raw_content": ..., + "timestamp": ..., + "created_at": ..., + "has_html": True/False, + "method": "Graph API" | "IMAP (New)" | "IMAP (Old)", +} +``` + +### 8.6 `wait_message_for_external()` + +伪码: + +```python +start = time.time() +while time.time() - start < timeout_seconds: + result = get_latest_message_for_external(...) + if result: + return result + time.sleep(poll_interval) +raise MailNotFoundError("等待超时,未检测到匹配邮件") +``` + +注意: +- 最大超时 120 秒 +- 默认 30 秒 +- 不使用异步任务,不引入 scheduler + +--- + +## 9. 邮件读取与回退链路细节 + +### 9.1 `_read_emails_with_fallback()` + +建议从 `controllers/emails.py` 现有逻辑下沉到 `services/external_api.py`。 + +函数签名: + +```python +def _read_emails_with_fallback(account: dict, folder: str, skip: int, top: int) -> dict: + """ + 返回: + { + "success": True, + "emails": [...], + "method": "Graph API" + } + 或抛出可识别异常。 + """ +``` + +### 9.2 Graph 优先读取 + +```python +graph_result = graph_service.get_emails_graph( + account["client_id"], + account["refresh_token"], + folder, + skip, + top, + proxy_url, +) +``` + +成功: +- 直接返回 +- `method = "Graph API"` + +失败: +- 收集 `graph_error` +- 若是 `ProxyError` / `ConnectionError`,直接中止并抛 `ProxyError` + +### 9.3 IMAP 回退读取 + +按顺序: + +1. `IMAP_SERVER_NEW = "outlook.live.com"` +2. `IMAP_SERVER_OLD = "outlook.office365.com"` + +成功时返回 `method = "IMAP (New)"` 或 `"IMAP (Old)"` + +### 9.4 `_read_email_detail_with_fallback()` + +实现顺序: + +1. `graph_service.get_email_detail_graph()` +2. `imap_service.get_email_detail_imap(..., folder)` +3. 若都失败,抛 `UpstreamReadFailedError` + +### 9.5 非 Outlook IMAP 兼容说明 + +当前开放 API 第一版目标是 Outlook 邮箱,但根项目已存在 `account_type == "imap"` 分支。 + +因此建议: +- 开放 API 仍允许对 `account_type == "imap"` 的账号调用 +- 列表读取时复用 `get_emails_imap_generic()` +- 详情读取时复用 `get_email_detail_imap_generic()` +- `preferred_method` 返回 `imap_generic` + +这样与 PRD 的“本地化部署邮件读取服务”目标更一致,也不把开放接口写死为仅支持 Outlook OAuth + +--- + +## 10. 验证码与验证链接提取细节 + +### 10.1 当前提取器能力 + +`outlook_web/services/verification_extractor.py` 已具备: + +- `smart_extract_verification_code()` +- `fallback_extract_verification_code()` +- `extract_links()` +- `extract_email_text()` +- `extract_verification_info()` + +### 10.2 新增参数化入口 + +新增: + +```python +def extract_verification_info_with_options( + email: Dict[str, Any], + *, + code_regex: str | None = None, + code_length: str | None = None, + code_source: str = "all", + prefer_link_keywords: list[str] | None = None, +) -> Dict[str, Any]: +``` + +### 10.3 `code_source` 实现 + +| 值 | 取值内容 | +|---|---| +| `subject` | 仅主题 | +| `content` | 仅纯文本正文 | +| `html` | 仅 HTML 内容 | +| `all` | 主题 + 纯文本 + HTML | + +伪码: + +```python +subject = email.get("subject", "") +content = extract_email_text(email) +html_content = email.get("body_html") or email.get("html_content") or "" + +if code_source == "subject": + source_text = subject +elif code_source == "content": + source_text = content +elif code_source == "html": + source_text = html_content +else: + source_text = f"{subject} {content} {html_content}".strip() +``` + +### 10.4 `code_regex` 实现 + +若传入 `code_regex`: +- 先 `re.compile(code_regex)` +- 编译失败抛 `InvalidParamError("code_regex 参数无效")` +- 编译成功后直接用于优先提取 + +### 10.5 `code_length` 实现 + +格式示例:`4-8`、`6-6` + +实现: + +```python +m = re.match(r"^(\d+)-(\d+)$", code_length) +if not m: + raise InvalidParamError("code_length 参数无效") +min_len = int(m.group(1)) +max_len = int(m.group(2)) +if min_len > max_len: + raise InvalidParamError("code_length 参数无效") +pattern = rf"\b\d{{{min_len},{max_len}}}\b" +``` + +### 10.6 验证链接优先级实现 + +默认优先关键词: + +```python +DEFAULT_LINK_KEYWORDS = [ + "verify", + "confirmation", + "confirm", + "activate", + "validation", +] +``` + +算法: +1. 提取全部链接 +2. 遍历关键词 +3. 返回第一个包含关键词的链接 +4. 若无命中,返回 `links[0]` +5. 若无链接,抛 `VerificationLinkNotFoundError` + +--- + +## 11. 统一响应与错误码映射 + +### 11.1 成功响应 + +```python +{ + "success": True, + "code": "OK", + "message": "success", + "data": {...}, +} +``` + +### 11.2 失败响应 + +```python +{ + "success": False, + "code": "ACCOUNT_NOT_FOUND", + "message": "邮箱账号不存在", + "data": None, +} +``` + +### 11.3 自定义异常建议 + +建议在 `services/external_api.py` 中定义轻量异常: + +```python +class ExternalApiError(Exception): + code = "INTERNAL_ERROR" + status = 500 + +class InvalidParamError(ExternalApiError): + code = "INVALID_PARAM" + status = 400 + +class AccountNotFoundError(ExternalApiError): + code = "ACCOUNT_NOT_FOUND" + status = 404 + +class MailNotFoundError(ExternalApiError): + code = "MAIL_NOT_FOUND" + status = 404 + +class VerificationCodeNotFoundError(ExternalApiError): + code = "VERIFICATION_CODE_NOT_FOUND" + status = 404 + +class VerificationLinkNotFoundError(ExternalApiError): + code = "VERIFICATION_LINK_NOT_FOUND" + status = 404 + +class ProxyError(ExternalApiError): + code = "PROXY_ERROR" + status = 502 + +class UpstreamReadFailedError(ExternalApiError): + code = "UPSTREAM_READ_FAILED" + status = 502 +``` + +### 11.4 错误码与 HTTP 映射 + +| code | HTTP | 触发条件 | +|---|---|---| +| `UNAUTHORIZED` | 401 | 缺少/错误 API Key | +| `API_KEY_NOT_CONFIGURED` | 403 | 系统未配置开放 API Key | +| `INVALID_PARAM` | 400 | 参数校验失败 | +| `ACCOUNT_NOT_FOUND` | 404 | 账号不存在 | +| `MAIL_NOT_FOUND` | 404 | 无匹配邮件 | +| `VERIFICATION_CODE_NOT_FOUND` | 404 | 邮件存在但无验证码 | +| `VERIFICATION_LINK_NOT_FOUND` | 404 | 邮件存在但无验证链接 | +| `PROXY_ERROR` | 502 | Graph 代理连接失败 | +| `UPSTREAM_READ_FAILED` | 502 | Graph/IMAP 全失败 | +| `INTERNAL_ERROR` | 500 | 未分类异常 | + +--- + +## 12. 系统自检接口实现细节 + +### 12.1 `api_external_health()` + +文件:`outlook_web/controllers/system.py` + +目标: +- API Key 鉴权后返回轻量服务健康信息 +- 不暴露管理员级详细调度器状态 + +建议实现: + +```python +@api_key_required +def api_external_health(): + conn = create_sqlite_connection() + try: + db_ok = True + try: + conn.execute("SELECT 1").fetchone() + except Exception: + db_ok = False + + return jsonify({ + "success": True, + "code": "OK", + "message": "success", + "data": { + "status": "ok" if db_ok else "degraded", + "service": "outlook-email-plus", + "server_time_utc": utcnow().isoformat() + "Z", + "database": "ok" if db_ok else "error", + }, + }) + finally: + conn.close() +``` + +### 12.2 `api_external_capabilities()` + +返回固定能力列表: + +```python +FEATURES = [ + "message_list", + "message_detail", + "raw_content", + "verification_code", + "verification_link", + "wait_message", +] +``` + +### 12.3 `api_external_account_status()` + +实现: +1. 校验 `email` +2. 查询 `accounts_repo.get_account_by_email(email)` +3. 若不存在,返回 `ACCOUNT_NOT_FOUND` +4. 存在则返回: + - `exists` + - `account_type` + - `provider` + - `group_id` + - `last_refresh_at` + - `preferred_method` + - `can_read=True` + +注意: +- 不在该接口中真正拉信 +- 该接口是自检接口,不是链路测试接口 + +--- + +## 13. 前端设置页改造细节 + +### 13.1 `templates/index.html` + +在系统设置页新增区块: + +- `externalApiKey`:密码输入框 +- `externalApiKeyHint`:显示“已配置/未配置”与脱敏值 +- `generateExternalApiKeyBtn`:生成随机 Key + +### 13.2 `static/js/main.js` + +#### 13.2.1 `loadSettings()` + +在现有 settings 拉取成功后追加: + +```javascript +const externalApiKeyInput = document.getElementById('externalApiKey'); +const externalApiKeyHint = document.getElementById('externalApiKeyHint'); +if (externalApiKeyInput) externalApiKeyInput.value = ''; +if (externalApiKeyHint) { + const masked = data.settings.external_api_key_masked || ''; + const isSet = !!data.settings.external_api_key_set; + externalApiKeyHint.textContent = isSet ? `已配置:${masked}` : '未配置'; +} +``` + +#### 13.2.2 `saveSettings()` + +```javascript +const externalApiKey = document.getElementById('externalApiKey')?.value?.trim() || ''; +settings.external_api_key = externalApiKey; +``` + +#### 13.2.3 随机生成按钮 + +```javascript +function generateExternalApiKey() { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + const value = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); + document.getElementById('externalApiKey').value = value; +} +``` + +--- + +## 14. 兼容性与回归保障 + +### 14.1 内部接口不变 + +以下接口不允许因开放接口改造而改变: + +- `GET /api/emails/` +- `GET /api/email//` +- `GET /api/emails//extract-verification` +- `GET /api/settings` +- `PUT /api/settings` + +### 14.2 加密兼容策略 + +`external_api_key` 读取逻辑必须兼容: +- 新数据:加密值 +- 历史手工写入:明文值 +- 空值:未配置 + +### 14.3 回退链路不变 + +开放接口与内部接口应共享相同的 Graph/IMAP 回退策略,避免两套行为分叉。 + +--- + +## 15. 测试策略与测试用例 + +### 15.1 测试文件建议 + +| 文件 | 说明 | +|---|---| +| `tests/test_external_api.py` | 开放接口主测试 | +| `tests/test_settings_external_api_key.py` | 设置项与加密兼容测试 | +| `tests/test_verification_extractor_options.py` | 提取器参数化测试 | + +### 15.2 鉴权测试 + +- 未传 `X-API-Key` → 401 `UNAUTHORIZED` +- 未配置 `external_api_key` → 403 `API_KEY_NOT_CONFIGURED` +- 错误 key → 401 `UNAUTHORIZED` +- 正确 key → 进入 controller + +### 15.3 `messages` 接口测试 + +- 正常返回列表 +- `folder=spam` → 400 +- 账号不存在 → 404 +- Graph 成功时 `method=Graph API` +- Graph 失败 IMAP 成功时返回成功 +- Graph 代理错误时返回 502 `PROXY_ERROR` + +### 15.4 `messages/latest` 测试 + +- 命中最新邮件 +- 无邮件 → 404 `MAIL_NOT_FOUND` + +### 15.5 `messages/{id}` / `raw` 测试 + +- 正常获取详情 +- 缺失 email → 400 +- message_id 无效 → 404/502(视底层行为映射) + +### 15.6 `verification-code` 测试 + +- 默认 4-8 位数字提取成功 +- `code_length=6-6` 生效 +- `code_regex` 生效 +- `code_regex` 非法 → 400 +- 邮件存在但无验证码 → 404 `VERIFICATION_CODE_NOT_FOUND` + +### 15.7 `verification-link` 测试 + +- 命中 `verify` 关键词链接 +- 无关键词时回退首个外链 +- 无链接 → 404 `VERIFICATION_LINK_NOT_FOUND` + +### 15.8 `wait-message` 测试 + +- 第一轮命中直接返回 +- 多轮轮询后命中 +- 超时返回 404 `MAIL_NOT_FOUND` +- `timeout_seconds > 120` → 400 + +### 15.9 系统接口测试 + +- `health` 返回 `status/service/database/server_time_utc` +- `capabilities` 返回固定 feature 列表 +- `account-status` 存在/不存在路径正确 + +--- + +## 16. 实施顺序建议 + +### 16.1 第一阶段:配置与鉴权 + +1. `db.py` 初始化 `external_api_key` +2. `repositories/settings.py` 增加读取/脱敏函数 +3. `security/auth.py` 增加 `api_key_required` +4. `controllers/settings.py` 与设置页对接 + +### 16.2 第二阶段:Service 抽离 + +1. 新增 `services/external_api.py` +2. 抽出 `_read_emails_with_fallback()` +3. 抽出 `_read_email_detail_with_fallback()` +4. 增强 `verification_extractor.py` + +### 16.3 第三阶段:开放路由与 controller + +1. 注册 `/api/external/messages*` +2. 注册 `/api/external/verification-*` +3. 注册 `/api/external/health|capabilities|account-status` +4. 接入审计日志 + +### 16.4 第四阶段:测试与文档 + +1. 补充 `tests/test_external_api.py` +2. 对照 OpenAPI 做返回结构校验 +3. 更新 README / API 文档示例 + +--- + +## 结论 + +本 TDD 的核心落点是: + +- **把开放 API 做成一层薄而稳定的外壳**,底层仍复用现有内部邮件读取能力; +- **把验证码/链接提取从“页面能力”升级为“服务能力”**,并支持参数化配置; +- **把设置、鉴权、审计、健康检查一起落地**,让开放接口从第一版就具备可配置、可接入、可排查的完整闭环; +- **通过新增 `services/external_api.py` 控制复杂度**,避免继续把复杂逻辑塞回 controller。 diff --git a/docs/TEST/TEST-00008-对外验证码与邮件读取开放API-测试文档.md b/docs/TEST/TEST-00008-对外验证码与邮件读取开放API-测试文档.md new file mode 100644 index 0000000..20218e5 --- /dev/null +++ b/docs/TEST/TEST-00008-对外验证码与邮件读取开放API-测试文档.md @@ -0,0 +1,766 @@ +# TEST-00008|对外验证码与邮件读取开放 API — 测试文档 + +- **文档编号**: TEST-00008 +- **创建日期**: 2026-03-08 +- **版本**: V1.0 +- **状态**: 草案 +- **对齐 PRD**: `docs/PRD/PRD-00008-对外验证码与邮件读取开放API.md` +- **对齐 FD**: `docs/FD/FD-00008-对外验证码与邮件读取开放API.md` +- **对齐 TDD**: `docs/TDD/TDD-00008-对外验证码与邮件读取开放API.md` +- **对齐 OpenAPI**: `docs/FD/OPENAPI-00008-对外验证码与邮件读取开放API.yaml` +- **建议测试文件**: + - `tests/test_external_api.py` + - `tests/test_settings_external_api_key.py` + - `tests/test_verification_extractor_options.py` + +--- + +## 目录 + +1. [测试概述](#1-测试概述) +2. [测试策略](#2-测试策略) +3. [测试环境配置](#3-测试环境配置) +4. [P0 — 开放接口鉴权测试](#4-p0--开放接口鉴权测试) +5. [P0 — 开放消息接口功能测试](#5-p0--开放消息接口功能测试) +6. [P0 — 验证码与验证链接接口功能测试](#6-p0--验证码与验证链接接口功能测试) +7. [P0 — 系统自检接口功能测试](#7-p0--系统自检接口功能测试) +8. [P0 — 回归测试(旧接口零回归)](#8-p0--回归测试旧接口零回归) +9. [P1 — 设置页与配置测试](#9-p1--设置页与配置测试) +10. [P1 — 提取器参数化与边界测试](#10-p1--提取器参数化与边界测试) +11. [P1 — 审计日志与可观测性测试](#11-p1--审计日志与可观测性测试) +12. [手动验收测试清单](#12-手动验收测试清单) +13. [测试数据准备](#13-测试数据准备) +14. [验收标准与门禁](#14-验收标准与门禁) + +--- + +## 1. 测试概述 + +### 1.1 测试目标 + +本测试文档覆盖“对外验证码与邮件读取开放 API”功能的完整验证,重点确保: + +1. **新接口功能正确性** + - `/api/external/messages*` 系列接口可稳定返回邮件列表、详情和 RAW 内容 + - `/api/external/verification-code` 能正确提取验证码 + - `/api/external/verification-link` 能正确提取验证链接 + - `/api/external/wait-message` 能按超时与轮询参数正确工作 + - `/api/external/health`、`/api/external/capabilities`、`/api/external/account-status` 返回结构与 OpenAPI 一致 + +2. **安全与配置正确性** + - `X-API-Key` 鉴权严格生效 + - 未配置 `external_api_key` 时开放接口不可用 + - `external_api_key` 在设置接口中可正确保存、脱敏展示、兼容明文与加密值 + +3. **现有功能零回归** + - 旧的内部邮件接口仍然按原方式工作 + - 现有 settings 接口与设置页其他字段不受影响 + - 现有验证码提取接口 `GET /api/emails//extract-verification` 不被破坏 + - Graph → IMAP(New) → IMAP(Old) 回退链路保持一致 + +### 1.2 测试范围 + +**包含:** +- 开放接口鉴权测试 +- 开放接口功能测试 +- 开放接口参数校验测试 +- 开放接口错误码测试 +- 设置与 API Key 配置测试 +- 审计日志测试 +- 旧接口回归测试 +- 手动联调验收测试 + +**不包含:** +- 真实 Outlook/IMAP 外网连通性压测 +- 生产代理环境稳定性压测 +- 前端端到端自动化浏览器测试(可在后续补充) + +### 1.3 测试分类 + +| 类型 | 工具 | 目标 | +|---|---|---| +| 单元测试 | `unittest` + `unittest.mock` | 提取器、鉴权装饰器、参数校验 | +| 集成测试 | Flask test client + 临时 SQLite DB | Controller / Route / Repository / Settings | +| 回归测试 | 现有测试套件 + 定向补测 | 旧接口、旧设置、旧验证码提取 | +| 手动测试 | 浏览器 + curl/Postman | 配置页、联调、部署自检 | + +--- + +## 2. 测试策略 + +### 2.1 核心原则 + +1. **新接口与旧接口同时验证**:所有关键新功能上线前,必须带上对应旧接口回归测试。 +2. **优先做控制面测试**:先验证鉴权、配置、参数校验,再验证业务提取链路。 +3. **隔离外部网络依赖**:Graph、IMAP、时间等待统一通过 `mock` 处理,不依赖真实网络。 +4. **返回结构与 OpenAPI 对齐**:测试中必须校验 `success/code/message/data` 四元结构。 +5. **重点关注错误路径**:鉴权失败、账号不存在、无匹配邮件、无验证码、无链接、上游失败都要覆盖。 + +### 2.2 Mock 策略 + +| 被 Mock 对象 | 路径 | 用途 | +|---|---|---| +| `graph_service.get_emails_graph` | `outlook_web.controllers.emails.graph_service.get_emails_graph` 或 service 层路径 | 模拟 Graph 成功/失败 | +| `graph_service.get_email_detail_graph` | 同上 | 模拟详情读取 | +| `imap_service.get_emails_imap_with_server` | `outlook_web.controllers.emails.imap_service.get_emails_imap_with_server` 或 service 层路径 | 模拟 IMAP 回退成功/失败 | +| `imap_service.get_email_detail_imap` | 同上 | 模拟 IMAP 详情回退 | +| `get_emails_imap_generic` | `outlook_web.controllers.emails.get_emails_imap_generic` 或 service 层路径 | 模拟通用 IMAP 分支 | +| `get_email_detail_imap_generic` | 同上 | 模拟通用 IMAP 详情 | +| `time.sleep` | `outlook_web.services.external_api.time.sleep` | 测试 `wait-message` 时避免真实等待 | +| `log_audit` | `outlook_web.controllers.emails.log_audit` / `outlook_web.controllers.system.log_audit` | 校验审计调用 | + +### 2.3 测试优先级定义 + +| 优先级 | 含义 | +|---|---| +| P0 | 上线前必须全部通过;关系到功能可用性和回归安全 | +| P1 | 首版建议覆盖;关系到边界处理和可维护性 | +| P2 | 可后续补充;偏手工体验和长尾场景 | + +--- + +## 3. 测试环境配置 + +### 3.1 运行方式 + +```bash +# 运行开放接口专项测试 +python -m unittest tests.test_external_api -v + +# 运行设置相关测试 +python -m unittest tests.test_settings_external_api_key -v + +# 运行提取器参数化测试 +python -m unittest tests.test_verification_extractor_options -v + +# 运行全量测试(含回归) +python -m unittest discover -s tests -v +``` + +### 3.2 测试环境要求 + +- 使用临时 SQLite 数据库 +- 使用 `create_app()` 创建 Flask app 实例 +- 通过 `app.test_client()` 调用接口 +- 所有外部邮件读取依赖均通过 mock 隔离 + +### 3.3 通用测试前置 + +建议每个测试类在 `setUp()` 中完成: + +1. 创建临时 DB +2. 初始化 `settings` 表和 `accounts` 表基础数据 +3. 写入一个测试账号,例如:`user@outlook.com` +4. 设置或清空 `external_api_key` +5. 创建 `test_client` + +--- + +## 4. P0 — 开放接口鉴权测试 + +### TC-AUTH-01:未传 `X-API-Key` + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-AUTH-01 | +| **优先级** | P0 | +| **前置条件** | 系统已配置 `external_api_key=abc123` | +| **操作步骤** | 调用 `GET /api/external/health`,不带 Header | +| **预期结果** | 返回 `401`;JSON 中 `code=UNAUTHORIZED` | + +### TC-AUTH-02:系统未配置 `external_api_key` + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-AUTH-02 | +| **优先级** | P0 | +| **前置条件** | `settings.external_api_key=''` | +| **操作步骤** | 携带任意 `X-API-Key` 调用 `GET /api/external/health` | +| **预期结果** | 返回 `403`;`code=API_KEY_NOT_CONFIGURED` | + +### TC-AUTH-03:错误 API Key + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-AUTH-03 | +| **优先级** | P0 | +| **前置条件** | `external_api_key=abc123` | +| **操作步骤** | 携带 `X-API-Key: wrong-key` 调用开放接口 | +| **预期结果** | 返回 `401`;`code=UNAUTHORIZED` | + +### TC-AUTH-04:正确 API Key 放行 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-AUTH-04 | +| **优先级** | P0 | +| **前置条件** | `external_api_key=abc123` | +| **操作步骤** | 携带 `X-API-Key: abc123` 调用 `GET /api/external/health` | +| **预期结果** | 返回 `200`;`success=true` | + +--- + +## 5. P0 — 开放消息接口功能测试 + +### TC-MSG-01:获取邮件列表成功(Graph 成功) + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-01 | +| **优先级** | P0 | +| **Mock** | `get_emails_graph` 返回 2 封邮件 | +| **操作步骤** | 调用 `GET /api/external/messages?email=user@outlook.com` | +| **预期结果** | 返回 `200`;`data.emails` 长度为 2;响应结构符合 OpenAPI | + +### TC-MSG-02:获取邮件列表成功(Graph 失败,IMAP New 成功) + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-02 | +| **优先级** | P0 | +| **Mock** | Graph 失败,`get_emails_imap_with_server(..., IMAP_SERVER_NEW)` 成功 | +| **预期结果** | 返回 `200`;`method=IMAP (New)` 或结果中能识别回退成功 | + +### TC-MSG-03:账号不存在 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-03 | +| **优先级** | P0 | +| **操作步骤** | 调用 `GET /api/external/messages?email=missing@outlook.com` | +| **预期结果** | 返回 `404`;`code=ACCOUNT_NOT_FOUND` | + +### TC-MSG-04:folder 参数非法 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-04 | +| **优先级** | P0 | +| **操作步骤** | 调用 `GET /api/external/messages?email=user@outlook.com&folder=spam` | +| **预期结果** | 返回 `400`;`code=INVALID_PARAM` | + +### TC-MSG-05:top 参数越界 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-05 | +| **优先级** | P0 | +| **操作步骤** | `top=0` 或 `top=999` | +| **预期结果** | 返回 `400` 或被截断到有效值(以最终实现为准,但必须与 OpenAPI 保持一致) | + +### TC-MSG-06:按 `from_contains` 过滤 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-06 | +| **优先级** | P0 | +| **Mock** | 返回多封不同发件人的邮件 | +| **操作步骤** | 调用 `GET /api/external/messages?...&from_contains=openai` | +| **预期结果** | 仅返回命中发件人关键字的邮件 | + +### TC-MSG-07:按 `subject_contains` 过滤 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-07 | +| **优先级** | P0 | +| **预期结果** | 仅返回主题命中的邮件 | + +### TC-MSG-08:按 `since_minutes` 过滤 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-08 | +| **优先级** | P0 | +| **Mock** | 返回新旧时间混合邮件 | +| **预期结果** | 仅返回时间窗口内邮件 | + +### TC-MSG-09:获取最新匹配邮件成功 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-09 | +| **优先级** | P0 | +| **Mock** | 返回多封按时间可区分邮件 | +| **预期结果** | `/api/external/messages/latest` 返回最新一封 | + +### TC-MSG-10:最新邮件不存在 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-10 | +| **优先级** | P0 | +| **预期结果** | 返回 `404`;`code=MAIL_NOT_FOUND` | + +### TC-MSG-11:获取邮件详情成功(Graph 详情成功) + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-11 | +| **优先级** | P0 | +| **Mock** | `get_email_detail_graph` 返回完整正文 | +| **操作步骤** | 调用 `/api/external/messages/{message_id}?email=user@outlook.com` | +| **预期结果** | 返回 `content/html_content/raw_content` 等字段 | + +### TC-MSG-12:获取 RAW 成功 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-12 | +| **优先级** | P0 | +| **预期结果** | `/api/external/messages/{message_id}/raw` 只返回 `raw_content` 主体数据 | + +### TC-MSG-13:详情读取 Graph 失败后 IMAP 回退成功 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-13 | +| **优先级** | P0 | +| **预期结果** | 返回 `200`,并标识实际读取方式 | + +### TC-MSG-14:上游全部失败 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-14 | +| **优先级** | P0 | +| **Mock** | Graph/IMAP 均失败 | +| **预期结果** | 返回 `502`;`code=UPSTREAM_READ_FAILED` | + +### TC-MSG-15:代理错误短路 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-MSG-15 | +| **优先级** | P0 | +| **Mock** | Graph 返回 `ProxyError` | +| **预期结果** | 返回 `502`;`code=PROXY_ERROR`;不继续走 IMAP | + +--- + +## 6. P0 — 验证码与验证链接接口功能测试 + +### TC-VER-01:默认规则提取验证码成功 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-VER-01 | +| **优先级** | P0 | +| **Mock** | 最新邮件正文包含 `Your code is 123456` | +| **操作步骤** | 调用 `/api/external/verification-code?email=user@outlook.com` | +| **预期结果** | 返回 `verification_code=123456` | + +### TC-VER-02:`code_length=6-6` 生效 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-VER-02 | +| **优先级** | P0 | +| **Mock** | 邮件正文同时存在 4 位和 6 位数字 | +| **预期结果** | 只提取 6 位验证码 | + +### TC-VER-03:`code_regex` 生效 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-VER-03 | +| **优先级** | P0 | +| **Mock** | 邮件正文为 `OTP: AB12CD` | +| **操作步骤** | 带 `code_regex=\b[A-Z0-9]{6}\b` | +| **预期结果** | 正确提取 `AB12CD` | + +### TC-VER-04:`code_regex` 非法 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-VER-04 | +| **优先级** | P0 | +| **操作步骤** | 传非法正则 | +| **预期结果** | 返回 `400`;`code=INVALID_PARAM` | + +### TC-VER-05:`code_source=subject` 只从主题提取 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-VER-05 | +| **优先级** | P0 | +| **Mock** | 主题中有验证码,正文中无验证码 | +| **预期结果** | 提取成功 | + +### TC-VER-06:邮件存在但无验证码 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-VER-06 | +| **优先级** | P0 | +| **预期结果** | 返回 `404`;`code=VERIFICATION_CODE_NOT_FOUND` | + +### TC-VER-07:提取验证链接成功(命中高优先级关键字) + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-VER-07 | +| **优先级** | P0 | +| **Mock** | 邮件中包含 `https://example.com/verify?token=abc` | +| **预期结果** | 返回该链接 | + +### TC-VER-08:无高优先级关键字时回退首个链接 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-VER-08 | +| **优先级** | P0 | +| **Mock** | 邮件中只有普通链接 | +| **预期结果** | 返回首个有效链接 | + +### TC-VER-09:邮件存在但无验证链接 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-VER-09 | +| **优先级** | P0 | +| **预期结果** | 返回 `404`;`code=VERIFICATION_LINK_NOT_FOUND` | + +### TC-VER-10:等待新邮件第一轮即命中 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-VER-10 | +| **优先级** | P0 | +| **Mock** | 第一次轮询即返回邮件 | +| **预期结果** | `/api/external/wait-message` 立即返回 `200` | + +### TC-VER-11:等待新邮件多轮后命中 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-VER-11 | +| **优先级** | P0 | +| **Mock** | 前两次为空,第三次返回邮件 | +| **预期结果** | 返回 `200`;`time.sleep` 被调用两次 | + +### TC-VER-12:等待新邮件超时 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-VER-12 | +| **优先级** | P0 | +| **Mock** | 始终无邮件 | +| **预期结果** | 返回 `404`;`code=MAIL_NOT_FOUND` | + +### TC-VER-13:`timeout_seconds` 越界 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-VER-13 | +| **优先级** | P0 | +| **操作步骤** | `timeout_seconds=999` | +| **预期结果** | 返回 `400`;`code=INVALID_PARAM` | + +--- + +## 7. P0 — 系统自检接口功能测试 + +### TC-SYS-01:`/api/external/health` 成功 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-SYS-01 | +| **优先级** | P0 | +| **预期结果** | 返回 `status/service/server_time_utc/database` | + +### TC-SYS-02:`/api/external/capabilities` 成功 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-SYS-02 | +| **优先级** | P0 | +| **预期结果** | 返回固定 feature 列表;包含 `message_list`、`verification_code`、`verification_link` | + +### TC-SYS-03:`/api/external/account-status` 成功 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-SYS-03 | +| **优先级** | P0 | +| **前置条件** | 测试账号存在 | +| **预期结果** | 返回 `exists=true` 与账号基本字段 | + +### TC-SYS-04:`account-status` 账号不存在 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-SYS-04 | +| **优先级** | P0 | +| **预期结果** | 返回 `404`;`code=ACCOUNT_NOT_FOUND` | + +--- + +## 8. P0 — 回归测试(旧接口零回归) + +### TC-REG-01:旧邮件列表接口仍可用 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REG-01 | +| **优先级** | P0 | +| **操作步骤** | 使用登录态调用 `GET /api/emails/` | +| **预期结果** | 返回结构与旧实现保持一致 | + +### TC-REG-02:旧邮件详情接口仍可用 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REG-02 | +| **优先级** | P0 | +| **操作步骤** | 使用登录态调用 `GET /api/email//` | +| **预期结果** | 返回旧结构,不因开放接口改造失败 | + +### TC-REG-03:旧验证码提取接口仍可用 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REG-03 | +| **优先级** | P0 | +| **操作步骤** | 使用登录态调用 `GET /api/emails//extract-verification` | +| **预期结果** | 仍可提取验证码/链接 | + +### TC-REG-04:设置接口旧字段不受影响 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REG-04 | +| **优先级** | P0 | +| **操作步骤** | `GET /api/settings` | +| **预期结果** | `refresh_interval_days`、`gptmail_api_key`、Telegram 配置等旧字段行为不变 | + +### TC-REG-05:设置保存旧字段不受影响 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REG-05 | +| **优先级** | P0 | +| **操作步骤** | `PUT /api/settings` 只修改旧字段 | +| **预期结果** | 旧字段正常保存;不要求必须同时提交 `external_api_key` | + +### TC-REG-06:Graph → IMAP 回退链不变 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-REG-06 | +| **优先级** | P0 | +| **Mock** | Graph 失败,IMAP 成功 | +| **预期结果** | 内部接口与开放接口均按同一回退策略返回 | + +--- + +## 9. P1 — 设置页与配置测试 + +### TC-SET-01:`external_api_key` 保存成功 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-SET-01 | +| **优先级** | P1 | +| **操作步骤** | 登录后 `PUT /api/settings`,提交 `external_api_key=abc123` | +| **预期结果** | 返回成功;DB 中存在该值(建议加密) | + +### TC-SET-02:`external_api_key` 脱敏返回 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-SET-02 | +| **优先级** | P1 | +| **操作步骤** | 登录后 `GET /api/settings` | +| **预期结果** | 返回 `external_api_key_set=true` 和 `external_api_key_masked`;不返回明文 | + +### TC-SET-03:兼容历史明文值 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-SET-03 | +| **优先级** | P1 | +| **前置条件** | 手工向 DB 写入明文 `external_api_key` | +| **预期结果** | 读取仍正常,鉴权仍有效 | + +### TC-SET-04:清空 API Key + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-SET-04 | +| **优先级** | P1 | +| **操作步骤** | `PUT /api/settings`,提交空字符串 | +| **预期结果** | 之后开放接口返回 `403 API_KEY_NOT_CONFIGURED` | + +--- + +## 10. P1 — 提取器参数化与边界测试 + +### TC-EXT-01:`code_source=html` 仅从 HTML 提取 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-EXT-01 | +| **优先级** | P1 | +| **预期结果** | 只从 HTML 内容命中验证码 | + +### TC-EXT-02:`code_length` 格式非法 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-EXT-02 | +| **优先级** | P1 | +| **输入** | `code_length=abc`、`8-4` | +| **预期结果** | 返回 `400 INVALID_PARAM` | + +### TC-EXT-03:多个候选验证码时优先关键词邻近值 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-EXT-03 | +| **优先级** | P1 | +| **Mock** | 邮件中存在多个数字 | +| **预期结果** | 优先提取靠近 `code` / `OTP` 等关键词的值 | + +### TC-EXT-04:多个链接时优先 `verify`/`confirm` + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-EXT-04 | +| **优先级** | P1 | +| **预期结果** | 优先返回高优先级链接 | + +--- + +## 11. P1 — 审计日志与可观测性测试 + +### TC-AUD-01:开放接口成功调用写审计日志 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-AUD-01 | +| **优先级** | P1 | +| **操作步骤** | 成功调用任一开放接口 | +| **预期结果** | `audit_logs` 中新增一条 `action=external_api_access` 记录 | + +### TC-AUD-02:开放接口失败调用也写审计日志 + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-AUD-02 | +| **优先级** | P1 | +| **操作步骤** | 触发 `MAIL_NOT_FOUND` 或 `INVALID_PARAM` | +| **预期结果** | 仍有审计记录,且 details 中包含错误码 | + +### TC-AUD-03:日志中不包含明文 API Key + +| 属性 | 值 | +|---|---| +| **测试 ID** | TC-AUD-03 | +| **优先级** | P1 | +| **预期结果** | `audit_logs.details` 中不出现完整 API Key | + +--- + +## 12. 手动验收测试清单 + +### 12.1 设置页验收 + +- 登录后台 +- 进入系统设置 +- 配置开放 API Key +- 保存后刷新页面 +- 确认页面仅显示脱敏值,不显示明文 + +### 12.2 curl 联调验收 + +```bash +curl -H "X-API-Key: your-api-key" "http://localhost:5000/api/external/health" + +curl -H "X-API-Key: your-api-key" "http://localhost:5000/api/external/messages?email=user@outlook.com" + +curl -H "X-API-Key: your-api-key" "http://localhost:5000/api/external/verification-code?email=user@outlook.com&subject_contains=verify&since_minutes=10" +``` + +验收点: +- 能正确返回 JSON +- 错误码与 message 清晰 +- OpenAPI 字段与实际响应一致 + +### 12.3 本地部署联调验收 + +- 部署实例 +- 配置 API Key +- 通过 `/api/external/health` 检查服务状态 +- 通过 `/api/external/account-status` 确认测试邮箱存在 +- 发一封带验证码的测试邮件 +- 通过 `/api/external/verification-code` 读取验证码 + +--- + +## 13. 测试数据准备 + +### 13.1 测试账号 + +| 邮箱 | 类型 | 用途 | +|---|---|---| +| `user@outlook.com` | outlook | 主流程测试 | +| `missing@outlook.com` | 不存在 | 404 测试 | +| `imap@example.com` | imap | 通用 IMAP 分支兼容测试 | + +### 13.2 模拟邮件数据 + +#### 验证码邮件 + +```python +{ + "id": "msg-001", + "subject": "Your verification code", + "from": {"emailAddress": {"address": "noreply@example.com"}}, + "receivedDateTime": "2026-03-08T12:00:00Z", + "bodyPreview": "Your code is 123456", +} +``` + +#### 验证链接邮件 + +```python +{ + "id": "msg-002", + "subject": "Please verify your email", + "from": {"emailAddress": {"address": "noreply@example.com"}}, + "receivedDateTime": "2026-03-08T12:01:00Z", + "bodyPreview": "Click https://example.com/verify?token=abc", +} +``` + +--- + +## 14. 验收标准与门禁 + +### 14.1 上线前最低门禁 + +- 所有 P0 测试用例通过 +- 旧接口回归测试全部通过 +- OpenAPI 中定义的核心接口返回结构与实际一致 +- 设置页中 API Key 脱敏展示正确 +- `python -m unittest discover -s tests -v` 无新增回归失败 + +### 14.2 建议覆盖率目标 + +| 模块 | 覆盖率目标 | +|---|---| +| `outlook_web/services/external_api.py` | 85%+ | +| `outlook_web/security/auth.py` 新增部分 | 90%+ | +| `outlook_web/services/verification_extractor.py` 新增部分 | 85%+ | +| `outlook_web/controllers/emails.py` 新增开放接口部分 | 80%+ | + +### 14.3 验收结论标准 + +满足以下条件视为可进入开发完成态: + +1. 新开放接口功能可用 +2. 旧接口无明显回归 +3. 鉴权与配置链路闭环 +4. 核心错误码与 OpenAPI 一致 +5. 手动联调可完成“配置 → 健康检查 → 查邮件 → 取验证码/链接”全流程 diff --git a/docs/待办事项.md b/docs/待办事项.md index 4606bec..9166eec 100644 --- a/docs/待办事项.md +++ b/docs/待办事项.md @@ -67,3 +67,221 @@ mail.uid( 建议: - 账号列表 tag 展示优先使用 `GET /api/providers` 返回的 `label`(由前端缓存映射),移除硬编码 `getProviderLabel()` 或仅作为兜底。 +--- + +## TODO-00008|对外验证码与邮件读取开放 API — 实施待办/开发清单 + +### 总体评价 + +`PRD / FD / OpenAPI / TDD / TEST` 已完成,且针对新能力的专项测试已先行落地: + +- `tests/test_external_api.py` +- `tests/test_settings_external_api_key.py` +- `tests/test_verification_extractor_options.py` +- `tests/test_ui_settings_external_api_key.py`(新增:前端设置页 external_api_key 集成验证) + +当前项目已经进入**按测试驱动实现代码**阶段。现状不是“需求不清”,而是“实现尚未补齐”: + +- ✅ 开放接口 `/api/external/*` 已注册并实现(统一响应结构 + 审计打点) +- ✅ `external_api_key` 设置项已接入(加密存储 + 脱敏回显)且 `api_key_required()` 已实现 +- ✅ 参数化提取函数 `extract_verification_info_with_options()` 已实现 + +验证通过: + +- `python -m unittest tests.test_settings_external_api_key -v` +- `python -m unittest tests.test_verification_extractor_options -v` +- `python -m unittest tests.test_external_api -v` +- `python -m unittest discover -s tests -v`(256 tests) + +执行日志:`docs/LOG/LOG-00008-对外验证码与邮件读取开放API-执行日志.md` + +### 具体问题列表(按严重程度排序) + +1) **开放 API 鉴权与配置链路未落地(高)— 已完成** +- 现状:`/api/settings` 已支持 `external_api_key` 的保存、脱敏与状态回显;`api_key_required()` 已实现。 +- 影响:开放接口已形成独立于后台登录态的调用闭环。 +- 关联文件:`outlook_web/controllers/settings.py`、`outlook_web/repositories/settings.py`、`outlook_web/security/auth.py`、`outlook_web/db.py` + +2) **开放系统接口未实现(高)— 已完成** +- 现状:`/api/external/health`、`/api/external/capabilities`、`/api/external/account-status` 已实现。 +- 影响:本地化部署接入自检与能力探测已可用。 +- 关联文件:`outlook_web/routes/system.py`、`outlook_web/controllers/system.py` + +3) **验证码/验证链接参数化提取能力未实现(高)— 已完成** +- 现状:已新增 `extract_verification_info_with_options()`,支持 `code_regex`、`code_length`、`code_source` 等可配置入口。 +- 影响:开放接口已可覆盖更多平台注册邮件模板,减少接入方二次解析成本。 +- 关联文件:`outlook_web/services/verification_extractor.py` + +4) **开放邮件读取接口未实现(高)— 已完成** +- 现状:`/api/external/messages`、`/latest`、`/{message_id}`、`/{message_id}/raw`、`/verification-code`、`/verification-link`、`/wait-message` 已落地。 +- 影响:开放 API 主链路已可用。 +- 关联文件:`outlook_web/routes/emails.py`、`outlook_web/controllers/emails.py`、`outlook_web/services/external_api.py` + +5) **统一错误码/审计日志闭环未完成(中)— 已完成** +- 现状:开放接口统一返回结构(`success/code/message/data`)与审计打点已收口。 +- 影响:外部调用调试与观测口径一致。 +- 关联文件:`outlook_web/controllers/emails.py`、`outlook_web/controllers/system.py`、`outlook_web/services/external_api.py` + +6) **前端设置页与手工验收路径待补齐(低)— 已完成** +- 现状:设置页已增加 `external_api_key` 的展示与保存入口(仅脱敏回显,避免占位符写回 DB)。 +- 影响:本地部署可直接在 UI 完成对外 API Key 配置与禁用,无需手改数据库。 +- 关联文件:`templates/index.html`、`static/js/main.js`、`outlook_web/controllers/settings.py` + +### 改进建议和实施顺序 + +#### A) 第一阶段:先打通配置与鉴权(P0,建议优先) + +目标:让开放接口具备最小可用的认证闭环。 + +实施项: + +- 在 `outlook_web/db.py` 为 `settings` 初始化 `external_api_key` +- 在 `outlook_web/repositories/settings.py` 增加: + - `get_external_api_key()` + - `get_external_api_key_masked()` +- 在 `outlook_web/controllers/settings.py`: + - `GET /api/settings` 返回 `external_api_key_set`、`external_api_key_masked` + - `PUT /api/settings` 支持保存 `external_api_key` +- 在 `outlook_web/security/auth.py` 新增 `api_key_required()`,仅接受 `X-API-Key` + +建议先跑: + +```bash +python -m unittest tests.test_settings_external_api_key -v +``` + +验收标准: + +- 设置接口可保存开放 API Key +- 设置读取时不回显明文 +- 未配置/缺失/错误 API Key 的开放接口行为与测试预期一致 + +#### B) 第二阶段:补开放系统自检接口(P0) + +目标:提供本地化部署后的接入探测能力。 + +实施项: + +- 在 `outlook_web/routes/system.py` 注册: + - `GET /api/external/health` + - `GET /api/external/capabilities` + - `GET /api/external/account-status` +- 在 `outlook_web/controllers/system.py` 实现统一返回结构与状态码 + +建议先跑: + +```bash +python -m unittest tests.test_external_api.ExternalApiAuthTests -v +python -m unittest tests.test_external_api.ExternalApiSystemTests -v +``` + +验收标准: + +- 正确 API Key 可通过系统自检接口 +- 返回字段与 OpenAPI 草稿一致(`capabilities.version`、`account-status.can_read` 等) +- `account-status` 能区分账号存在/不存在/不可用等状态(含 `account_type/provider/preferred_method`) + +#### C) 第三阶段:补参数化提取器(P0) + +目标:让验证码与验证链接提取逻辑满足外部接入场景。 + +实施项: + +- 在 `outlook_web/services/verification_extractor.py` 新增 `extract_verification_info_with_options()` +- 支持: + - `code_regex` + - `code_length` + - `code_source` + - 高置信验证链接优先级(`verify` / `confirm` / `activate` / `validation`) + +建议先跑: + +```bash +python -m unittest tests.test_verification_extractor_options -v +``` + +验收标准: + +- 默认行为不破坏旧提取能力 +- 自定义正则和长度范围都能生效 +- 链接优先返回高置信验证链接 + +#### D) 第四阶段:补开放邮件与自动化接口(P0) + +目标:完成开放 API 主业务链路。 + +实施项: + +- 建议新增 `outlook_web/services/external_api.py`,统一封装: + - 账号读取 + - Graph → IMAP(New) → IMAP(Old) 回退 + - 列表筛选 + - 最新邮件定位 + - 详情读取 + - `wait-message` 轮询 +- 在 `outlook_web/routes/emails.py` 注册: + - `GET /api/external/messages` + - `GET /api/external/messages/latest` + - `GET /api/external/messages/` + - `GET /api/external/messages//raw` + - `GET /api/external/verification-code` + - `GET /api/external/verification-link` + - `GET /api/external/wait-message` +- 在 `outlook_web/controllers/emails.py` 落地统一响应、参数校验与审计打点 + +建议先跑: + +```bash +python -m unittest tests.test_external_api -v +``` + +验收标准: + +- 邮件列表、详情、RAW、验证码、验证链接、等待邮件均可调用 +- 返回结构统一为 `success/code/message/data` +- Graph 与 IMAP 回退链路不影响旧内部接口 + +#### E) 第五阶段:做回归封板与手工验收(P1) + +目标:确认“新接口上线、旧接口零回归”。 + +实施项: + +- 跑专项测试: + +```bash +python -m unittest tests.test_external_api -v +python -m unittest tests.test_settings_external_api_key -v +python -m unittest tests.test_verification_extractor_options -v +``` + +- 跑全量回归: + +```bash +python -m unittest discover -s tests -v +``` + +- 按 `docs/TEST/TEST-00008-对外验证码与邮件读取开放API-测试文档.md` 执行一次手工联调 + +验收标准: + +- 新增测试全部转绿 +- 旧接口相关回归测试通过 +- 文档、OpenAPI、真实实现三者保持一致 + +### 建议里程碑 + +| 里程碑 | 目标 | 完成标志 | +|---|---|---| +| M1 | 配置与鉴权闭环 | `test_settings_external_api_key` 通过 | +| M2 | 系统自检可用 | Auth + System 相关测试通过 | +| M3 | 提取器参数化完成 | `test_verification_extractor_options` 通过 | +| M4 | 开放主链路可用 | `test_external_api` 通过 | +| M5 | 回归封板 | `discover -s tests` 通过 | + +### 风险提示 + +- **兼容性风险**:开放接口新增时,不能改坏现有内部接口 URL 与返回结构。 +- **安全风险**:不得在日志、设置页返回、异常栈中暴露明文 `external_api_key`。 +- **实现风险**:若 controller 内继续堆逻辑,后续维护成本会快速上升,建议抽出 `external_api.py` service。 +- **测试风险**:`wait-message` 涉及时间轮询,必须通过 `mock sleep` 保证测试稳定、快速。 diff --git a/outlook_web/controllers/emails.py b/outlook_web/controllers/emails.py index 280c21d..6da9696 100644 --- a/outlook_web/controllers/emails.py +++ b/outlook_web/controllers/emails.py @@ -11,8 +11,9 @@ from outlook_web.db import get_db from outlook_web.errors import build_error_payload from outlook_web.repositories import accounts as accounts_repo from outlook_web.repositories import groups as groups_repo -from outlook_web.security.auth import login_required +from outlook_web.security.auth import api_key_required, login_required from outlook_web.services import email_delete as email_delete_service +from outlook_web.services import external_api as external_api_service from outlook_web.services import graph as graph_service from outlook_web.services import imap as imap_service from outlook_web.services.imap_generic import get_email_detail_imap_generic, get_emails_imap_generic @@ -575,3 +576,322 @@ def api_extract_verification(email_addr: str) -> Any: # 其他错误 error_payload = build_error_payload("EXTRACT_ERROR", "提取失败", "ExtractError", 500, str(e)) return jsonify({"success": False, "error": error_payload}), 500 + + +# ==================== External Emails API ==================== + + +def _parse_external_common_args() -> dict: + """解析 external API 通用 query 参数(按 TDD-00008 做基础校验)。""" + email_addr = (request.args.get("email") or "").strip() + if not email_addr or "@" not in email_addr: + raise external_api_service.InvalidParamError("email 参数无效") + + folder = (request.args.get("folder") or "inbox").strip().lower() or "inbox" + if folder not in {"inbox", "junkemail", "deleteditems"}: + raise external_api_service.InvalidParamError("folder 参数无效") + + def _int_arg(name: str, default: int) -> int: + raw = request.args.get(name, None) + if raw is None or raw == "": + return default + try: + return int(raw) + except Exception as exc: + raise external_api_service.InvalidParamError(f"{name} 参数无效") from exc + + skip = _int_arg("skip", 0) + top = _int_arg("top", 20) + if skip < 0: + raise external_api_service.InvalidParamError("skip 参数无效") + if top < 1 or top > 50: + raise external_api_service.InvalidParamError("top 参数无效") + + since_minutes_raw = request.args.get("since_minutes", None) + since_minutes = None + if since_minutes_raw not in (None, ""): + try: + since_minutes = int(since_minutes_raw) + except Exception as exc: + raise external_api_service.InvalidParamError("since_minutes 参数无效") from exc + if since_minutes < 1: + raise external_api_service.InvalidParamError("since_minutes 参数无效") + + return { + "email": email_addr, + "folder": folder, + "skip": skip, + "top": top, + "from_contains": (request.args.get("from_contains") or "").strip(), + "subject_contains": (request.args.get("subject_contains") or "").strip(), + "since_minutes": since_minutes, + } + + +def _external_error_response(exc: external_api_service.ExternalApiError): + return jsonify(external_api_service.fail(exc.code, exc.message, data=exc.data)), exc.status + + +@api_key_required +def api_external_get_messages() -> Any: + try: + args = _parse_external_common_args() + emails, method = external_api_service.list_messages_for_external( + email_addr=args["email"], + folder=args["folder"], + skip=args["skip"], + top=args["top"], + ) + filtered = external_api_service.filter_messages( + emails, + from_contains=args["from_contains"], + subject_contains=args["subject_contains"], + since_minutes=args["since_minutes"], + ) + + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=args["email"] or "", + endpoint="/api/external/messages", + status="ok", + details={"method": method, "count": len(filtered)}, + ) + + return jsonify(external_api_service.ok({"emails": filtered, "count": len(filtered), "has_more": False})) + except external_api_service.ExternalApiError as exc: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=(request.args.get("email") or "").strip(), + endpoint="/api/external/messages", + status="error", + details={"code": exc.code}, + ) + return _external_error_response(exc) + except Exception as exc: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=(request.args.get("email") or "").strip(), + endpoint="/api/external/messages", + status="error", + details={"code": "INTERNAL_ERROR", "err": type(exc).__name__}, + ) + return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 + + +@api_key_required +def api_external_get_latest_message() -> Any: + try: + args = _parse_external_common_args() + latest = external_api_service.get_latest_message_for_external( + email_addr=args["email"], + folder=args["folder"], + from_contains=args["from_contains"], + subject_contains=args["subject_contains"], + since_minutes=args["since_minutes"], + ) + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=args["email"] or "", + endpoint="/api/external/messages/latest", + status="ok", + details={"method": latest.get("method")}, + ) + return jsonify(external_api_service.ok(latest)) + except external_api_service.ExternalApiError as exc: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=(request.args.get("email") or "").strip(), + endpoint="/api/external/messages/latest", + status="error", + details={"code": exc.code}, + ) + return _external_error_response(exc) + except Exception as exc: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=(request.args.get("email") or "").strip(), + endpoint="/api/external/messages/latest", + status="error", + details={"code": "INTERNAL_ERROR", "err": type(exc).__name__}, + ) + return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 + + +@api_key_required +def api_external_get_message_detail(message_id: str) -> Any: + try: + args = _parse_external_common_args() + detail = external_api_service.get_message_detail_for_external( + email_addr=args["email"], + message_id=message_id, + folder=args["folder"], + ) + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=args["email"] or "", + endpoint="/api/external/messages/{message_id}", + status="ok", + details={"method": detail.get("method")}, + ) + return jsonify(external_api_service.ok(detail)) + except external_api_service.ExternalApiError as exc: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=(request.args.get("email") or "").strip(), + endpoint="/api/external/messages/{message_id}", + status="error", + details={"code": exc.code}, + ) + return _external_error_response(exc) + except Exception as exc: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=(request.args.get("email") or "").strip(), + endpoint="/api/external/messages/{message_id}", + status="error", + details={"code": "INTERNAL_ERROR", "err": type(exc).__name__}, + ) + return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 + + +@api_key_required +def api_external_get_message_raw(message_id: str) -> Any: + try: + args = _parse_external_common_args() + detail = external_api_service.get_message_detail_for_external( + email_addr=args["email"], + message_id=message_id, + folder=args["folder"], + ) + return jsonify( + external_api_service.ok( + { + "id": message_id, + "email_address": args["email"], + "raw_content": detail.get("raw_content", ""), + "method": detail.get("method", ""), + } + ) + ) + except external_api_service.ExternalApiError as exc: + return _external_error_response(exc) + except Exception: + return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 + + +@api_key_required +def api_external_get_verification_code() -> Any: + try: + args = _parse_external_common_args() + code_length = (request.args.get("code_length") or "").strip() or None + code_regex = (request.args.get("code_regex") or "").strip() or None + code_source = (request.args.get("code_source") or "all").strip().lower() + if code_source not in {"subject", "content", "html", "all"}: + raise external_api_service.InvalidParamError("code_source 参数无效") + + result = external_api_service.get_verification_result( + email_addr=args["email"], + folder=args["folder"], + from_contains=args["from_contains"], + subject_contains=args["subject_contains"], + since_minutes=args["since_minutes"], + code_regex=code_regex, + code_length=code_length, + code_source=code_source, + ) + if not result.get("verification_code"): + raise external_api_service.VerificationCodeNotFoundError("未找到符合条件的验证码邮件") + + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=args["email"] or "", + endpoint="/api/external/verification-code", + status="ok", + details={"matched_email_id": result.get("matched_email_id"), "method": result.get("method")}, + ) + return jsonify(external_api_service.ok(result)) + except external_api_service.ExternalApiError as exc: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=(request.args.get("email") or "").strip(), + endpoint="/api/external/verification-code", + status="error", + details={"code": exc.code}, + ) + return _external_error_response(exc) + except ValueError: + return jsonify(external_api_service.fail("INVALID_PARAM", "参数错误")), 400 + except Exception: + return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 + + +@api_key_required +def api_external_get_verification_link() -> Any: + try: + args = _parse_external_common_args() + result = external_api_service.get_verification_result( + email_addr=args["email"], + folder=args["folder"], + from_contains=args["from_contains"], + subject_contains=args["subject_contains"], + since_minutes=args["since_minutes"], + ) + if not result.get("verification_link"): + raise external_api_service.VerificationLinkNotFoundError("未找到符合条件的验证链接邮件") + + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=args["email"] or "", + endpoint="/api/external/verification-link", + status="ok", + details={"matched_email_id": result.get("matched_email_id"), "method": result.get("method")}, + ) + return jsonify(external_api_service.ok(result)) + except external_api_service.ExternalApiError as exc: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=(request.args.get("email") or "").strip(), + endpoint="/api/external/verification-link", + status="error", + details={"code": exc.code}, + ) + return _external_error_response(exc) + except Exception: + return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 + + +@api_key_required +def api_external_wait_message() -> Any: + try: + args = _parse_external_common_args() + timeout_seconds = request.args.get("timeout_seconds", "30") + poll_interval = request.args.get("poll_interval", "5") + result = external_api_service.wait_for_message( + email_addr=args["email"], + timeout_seconds=int(timeout_seconds), + poll_interval=int(poll_interval), + folder=args["folder"], + from_contains=args["from_contains"], + subject_contains=args["subject_contains"], + since_minutes=args["since_minutes"], + ) + + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=args["email"] or "", + endpoint="/api/external/wait-message", + status="ok", + details={"matched_email_id": result.get("id"), "method": result.get("method")}, + ) + return jsonify(external_api_service.ok(result)) + except external_api_service.ExternalApiError as exc: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=(request.args.get("email") or "").strip(), + endpoint="/api/external/wait-message", + status="error", + details={"code": exc.code}, + ) + return _external_error_response(exc) + except Exception: + return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 diff --git a/outlook_web/controllers/settings.py b/outlook_web/controllers/settings.py index 673a25e..c130a8e 100644 --- a/outlook_web/controllers/settings.py +++ b/outlook_web/controllers/settings.py @@ -14,19 +14,20 @@ from outlook_web.security.crypto import decrypt_data, encrypt_data, hash_passwor # ==================== 设置 API ==================== +def _mask_secret_value(value: str, head: int = 4, tail: int = 4) -> str: + if not value: + return "" + safe_value = str(value) + if len(safe_value) <= head + tail: + return "*" * len(safe_value) + return safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:] + + @login_required def api_get_settings() -> Any: """获取所有设置""" all_settings = settings_repo.get_all_settings() - def mask_secret_value(value: str, head: int = 4, tail: int = 4) -> str: - if not value: - return "" - safe_value = str(value) - if len(safe_value) <= head + tail: - return "*" * len(safe_value) - return safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:] - # 仅返回前端需要的设置项(避免把敏感字段/内部状态直接返回) safe_settings = { "refresh_interval_days": all_settings.get("refresh_interval_days", "30"), @@ -43,9 +44,12 @@ def api_get_settings() -> Any: # 敏感字段:不返回明文/哈希,仅提供"是否已设置/脱敏展示" login_password_value = all_settings.get("login_password") or "" gptmail_api_key_value = all_settings.get("gptmail_api_key") or "" + external_api_key_value = settings_repo.get_external_api_key() safe_settings["login_password_set"] = bool(login_password_value) safe_settings["gptmail_api_key_set"] = bool(gptmail_api_key_value) - safe_settings["gptmail_api_key_masked"] = mask_secret_value(gptmail_api_key_value) if gptmail_api_key_value else "" + safe_settings["gptmail_api_key_masked"] = _mask_secret_value(gptmail_api_key_value) if gptmail_api_key_value else "" + safe_settings["external_api_key_set"] = bool(external_api_key_value) + safe_settings["external_api_key_masked"] = _mask_secret_value(external_api_key_value) if external_api_key_value else "" # Telegram 推送配置 tg_bot_token_raw = all_settings.get("telegram_bot_token", "") @@ -99,12 +103,39 @@ def api_update_settings() -> Any: # 更新 GPTMail API Key if "gptmail_api_key" in data: - new_api_key = data["gptmail_api_key"].strip() - if new_api_key: + new_api_key = str(data["gptmail_api_key"] or "").strip() + existing_api_key = settings_repo.get_setting("gptmail_api_key", "") or "" + if new_api_key and existing_api_key and new_api_key == _mask_secret_value(existing_api_key): + updated.append("GPTMail API Key(未变更)") + elif new_api_key: if settings_repo.set_setting("gptmail_api_key", new_api_key): updated.append("GPTMail API Key") else: errors.append("更新 GPTMail API Key 失败") + else: + # 允许清空(用于禁用临时邮箱能力) + if settings_repo.set_setting("gptmail_api_key", ""): + updated.append("GPTMail API Key(已清空)") + else: + errors.append("清空 GPTMail API Key 失败") + + # 更新对外开放 API Key(建议加密存储) + if "external_api_key" in data: + new_external_api_key = str(data["external_api_key"] or "").strip() + existing_external_api_key = settings_repo.get_external_api_key() + if new_external_api_key and existing_external_api_key and new_external_api_key == _mask_secret_value(existing_external_api_key): + updated.append("对外 API Key(未变更)") + elif new_external_api_key: + encrypted_key = encrypt_data(new_external_api_key) + if settings_repo.set_setting("external_api_key", encrypted_key): + updated.append("对外 API Key") + else: + errors.append("更新对外 API Key 失败") + else: + if settings_repo.set_setting("external_api_key", ""): + updated.append("对外 API Key(已清空)") + else: + errors.append("清空对外 API Key 失败") # 更新刷新周期 if "refresh_interval_days" in data: diff --git a/outlook_web/controllers/system.py b/outlook_web/controllers/system.py index c3237f6..1af837d 100644 --- a/outlook_web/controllers/system.py +++ b/outlook_web/controllers/system.py @@ -4,8 +4,9 @@ import time from datetime import datetime, timezone from typing import Any -from flask import jsonify +from flask import jsonify, request +from outlook_web import __version__ as APP_VERSION from outlook_web import config from outlook_web.db import ( DB_SCHEMA_LAST_UPGRADE_ERROR_KEY, @@ -14,8 +15,10 @@ from outlook_web.db import ( DB_SCHEMA_VERSION_KEY, create_sqlite_connection, ) +from outlook_web.repositories import accounts as accounts_repo from outlook_web.repositories import settings as settings_repo -from outlook_web.security.auth import login_required +from outlook_web.security.auth import api_key_required, login_required +from outlook_web.services import external_api as external_api_service # 常量 REFRESH_LOCK_NAME = "token_refresh" @@ -246,3 +249,86 @@ def api_system_upgrade_status() -> Any: ) finally: conn.close() + + +# ==================== External System API ==================== + + +@api_key_required +def api_external_health() -> Any: + """对外健康检查(不依赖登录态)""" + conn = create_sqlite_connection() + try: + db_ok = True + try: + conn.execute("SELECT 1").fetchone() + except Exception: + db_ok = False + + data = { + "status": "ok", + "service": "outlook-email-plus", + "version": APP_VERSION, + "server_time_utc": utcnow().isoformat() + "Z", + "database": "ok" if db_ok else "error", + } + return jsonify(external_api_service.ok(data)) + finally: + conn.close() + + +@api_key_required +def api_external_capabilities() -> Any: + """对外能力说明接口""" + data = { + "service": "outlook-email-plus", + "version": APP_VERSION, + "features": [ + "message_list", + "message_detail", + "raw_content", + "verification_code", + "verification_link", + "wait_message", + ], + } + return jsonify(external_api_service.ok(data)) + + +@api_key_required +def api_external_account_status() -> Any: + """对外账号状态检查""" + email_addr = (request.args.get("email") or "").strip() + if not email_addr or "@" not in email_addr: + return jsonify(external_api_service.fail("INVALID_PARAM", "email 参数不合法")), 400 + + account = accounts_repo.get_account_by_email(email_addr) + if not account: + return jsonify(external_api_service.fail("ACCOUNT_NOT_FOUND", "账号不存在", data={"email": email_addr})), 404 + + account_type = (account.get("account_type") or "outlook").strip().lower() + provider = (account.get("provider") or account_type or "outlook").strip().lower() + preferred_method = "imap_generic" if account_type == "imap" else "graph" + status = (account.get("status") or "active").strip().lower() + can_read = status != "disabled" + if can_read: + if account_type == "imap": + can_read = bool((account.get("imap_host") or "").strip()) and bool((account.get("imap_password") or "").strip()) + else: + can_read = bool((account.get("client_id") or "").strip()) and bool((account.get("refresh_token") or "").strip()) + + return jsonify( + external_api_service.ok( + { + "email": email_addr, + "exists": True, + "account_type": account_type, + "provider": provider, + "group_id": account.get("group_id"), + "status": account.get("status"), + "last_refresh_at": account.get("last_refresh_at"), + "preferred_method": preferred_method, + "can_read": can_read, + } + ) + ) diff --git a/outlook_web/db.py b/outlook_web/db.py index b090b35..5e98e6e 100644 --- a/outlook_web/db.py +++ b/outlook_web/db.py @@ -422,6 +422,14 @@ def init_db(database_path: Optional[str] = None): (gptmail_api_key_default,), ) + # PRD-00008 / FD-00008:对外开放 API Key(默认空,建议加密存储) + cursor.execute( + """ + INSERT OR IGNORE INTO settings (key, value) + VALUES ('external_api_key', '') + """ + ) + # 初始化刷新配置 cursor.execute(""" INSERT OR IGNORE INTO settings (key, value) diff --git a/outlook_web/repositories/settings.py b/outlook_web/repositories/settings.py index aaa70c5..acda831 100644 --- a/outlook_web/repositories/settings.py +++ b/outlook_web/repositories/settings.py @@ -4,6 +4,7 @@ from typing import Dict from outlook_web import config from outlook_web.db import get_db +from outlook_web.security.crypto import decrypt_data def get_setting(key: str, default: str = "") -> str: @@ -49,3 +50,32 @@ def get_gptmail_api_key() -> str: """获取 GPTMail API Key(优先从数据库读取)""" api_key = get_setting("gptmail_api_key") return api_key if api_key else config.get_gptmail_api_key_default() + + +def get_external_api_key() -> str: + """ + 获取对外开放 API Key。 + + - 若数据库为空,返回空字符串 + - 若为 enc: 加密格式,自动解密 + - 若为历史明文(兼容),直接返回明文 + - 解密失败时返回空字符串(避免影响外部接口鉴权逻辑) + """ + value = get_setting("external_api_key") or "" + if not value: + return "" + try: + return decrypt_data(value) + except Exception: + return "" + + +def get_external_api_key_masked(head: int = 4, tail: int = 4) -> str: + """对外 API Key 脱敏展示:前 N 位 + 若干 * + 后 N 位。""" + key = get_external_api_key() + if not key: + return "" + safe_value = str(key) + if len(safe_value) <= head + tail: + return "*" * len(safe_value) + return safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:] diff --git a/outlook_web/routes/emails.py b/outlook_web/routes/emails.py index f5d31de..e713be8 100644 --- a/outlook_web/routes/emails.py +++ b/outlook_web/routes/emails.py @@ -28,4 +28,41 @@ def create_blueprint() -> Blueprint: view_func=emails_controller.api_get_email_detail, methods=["GET"], ) + + # PRD-00008 / FD-00008:对外开放 API(仅 API Key 鉴权,不依赖登录态) + bp.add_url_rule( + "/api/external/messages", + view_func=emails_controller.api_external_get_messages, + methods=["GET"], + ) + bp.add_url_rule( + "/api/external/messages/latest", + view_func=emails_controller.api_external_get_latest_message, + methods=["GET"], + ) + bp.add_url_rule( + "/api/external/messages/", + view_func=emails_controller.api_external_get_message_detail, + methods=["GET"], + ) + bp.add_url_rule( + "/api/external/messages//raw", + view_func=emails_controller.api_external_get_message_raw, + methods=["GET"], + ) + bp.add_url_rule( + "/api/external/verification-code", + view_func=emails_controller.api_external_get_verification_code, + methods=["GET"], + ) + bp.add_url_rule( + "/api/external/verification-link", + view_func=emails_controller.api_external_get_verification_link, + methods=["GET"], + ) + bp.add_url_rule( + "/api/external/wait-message", + view_func=emails_controller.api_external_wait_message, + methods=["GET"], + ) return bp diff --git a/outlook_web/routes/system.py b/outlook_web/routes/system.py index 647ef67..3f33a6c 100644 --- a/outlook_web/routes/system.py +++ b/outlook_web/routes/system.py @@ -24,4 +24,21 @@ def create_blueprint() -> Blueprint: view_func=system_controller.api_system_upgrade_status, methods=["GET"], ) + + # PRD-00008 / FD-00008:对外开放系统自检接口(仅 API Key 鉴权) + bp.add_url_rule( + "/api/external/health", + view_func=system_controller.api_external_health, + methods=["GET"], + ) + bp.add_url_rule( + "/api/external/capabilities", + view_func=system_controller.api_external_capabilities, + methods=["GET"], + ) + bp.add_url_rule( + "/api/external/account-status", + view_func=system_controller.api_external_account_status, + methods=["GET"], + ) return bp diff --git a/outlook_web/security/auth.py b/outlook_web/security/auth.py index a1306d1..89a6d24 100644 --- a/outlook_web/security/auth.py +++ b/outlook_web/security/auth.py @@ -172,6 +172,67 @@ def login_required(f): return decorated_function +def api_key_required(f): + """ + 对外开放 API 的 API Key 校验装饰器。 + + 规则: + 1. 仅接受 Header 中的 `X-API-Key` + 2. 未配置 external_api_key 时返回 403(API_KEY_NOT_CONFIGURED) + 3. 缺少或错误时返回 401(UNAUTHORIZED) + 4. 不依赖 session,不触发登录跳转 + """ + + @wraps(f) + def decorated_function(*args, **kwargs): + from outlook_web.repositories import settings as settings_repo + + provided_key = (request.headers.get("X-API-Key") or "").strip() + if not provided_key: + return ( + jsonify( + { + "success": False, + "code": "UNAUTHORIZED", + "message": "API Key 缺失或无效", + "data": None, + } + ), + 401, + ) + + configured_key = settings_repo.get_external_api_key() + if not configured_key: + return ( + jsonify( + { + "success": False, + "code": "API_KEY_NOT_CONFIGURED", + "message": "系统未配置对外 API Key", + "data": None, + } + ), + 403, + ) + + if not secrets.compare_digest(str(provided_key), str(configured_key)): + return ( + jsonify( + { + "success": False, + "code": "UNAUTHORIZED", + "message": "API Key 缺失或无效", + "data": None, + } + ), + 401, + ) + + return f(*args, **kwargs) + + return decorated_function + + def get_client_ip() -> str: """ 获取客户端 IP(安全实现) diff --git a/outlook_web/services/external_api.py b/outlook_web/services/external_api.py new file mode 100644 index 0000000..4c84ce8 --- /dev/null +++ b/outlook_web/services/external_api.py @@ -0,0 +1,568 @@ +from __future__ import annotations + +import json +import time +from datetime import datetime, timedelta, timezone +from email.utils import parseaddr, parsedate_to_datetime +from typing import Any, Dict, List, Optional, Tuple + +from outlook_web.audit import log_audit +from outlook_web.repositories import accounts as accounts_repo +from outlook_web.repositories import groups as groups_repo +from outlook_web.services import graph as graph_service +from outlook_web.services import imap as imap_service +from outlook_web.services.imap_generic import get_email_detail_imap_generic, get_emails_imap_generic +from outlook_web.services.verification_extractor import extract_email_text, extract_verification_info_with_options + +# Outlook IMAP 回退服务器(保持与内部接口一致) +IMAP_SERVER_NEW = "outlook.live.com" +IMAP_SERVER_OLD = "outlook.office365.com" + +# wait-message 约束 +MAX_TIMEOUT_SECONDS = 120 + + +class ExternalApiError(Exception): + code = "INTERNAL_ERROR" + status = 500 + + def __init__(self, message: str, *, data: Any = None): + super().__init__(message) + self.message = message + self.data = data + + +class InvalidParamError(ExternalApiError): + code = "INVALID_PARAM" + status = 400 + + +class AccountNotFoundError(ExternalApiError): + code = "ACCOUNT_NOT_FOUND" + status = 404 + + +class MailNotFoundError(ExternalApiError): + code = "MAIL_NOT_FOUND" + status = 404 + + +class VerificationCodeNotFoundError(ExternalApiError): + code = "VERIFICATION_CODE_NOT_FOUND" + status = 404 + + +class VerificationLinkNotFoundError(ExternalApiError): + code = "VERIFICATION_LINK_NOT_FOUND" + status = 404 + + +class ProxyError(ExternalApiError): + code = "PROXY_ERROR" + status = 502 + + +class UpstreamReadFailedError(ExternalApiError): + code = "UPSTREAM_READ_FAILED" + status = 502 + + +def ok(data: Any = None, *, message: str = "success") -> Dict[str, Any]: + return {"success": True, "code": "OK", "message": message, "data": data} + + +def fail(code: str, message: str, *, data: Any = None) -> Dict[str, Any]: + return {"success": False, "code": code, "message": message, "data": data} + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_datetime(value: str) -> Optional[datetime]: + if not value: + return None + text = str(value).strip() + if not text: + return None + + # 1) ISO 8601(Graph 常见:2026-03-08T12:00:00Z) + try: + if text.endswith("Z"): + text = text[:-1] + "+00:00" + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except Exception: + pass + + # 2) RFC2822(IMAP Date header 常见) + try: + dt = parsedate_to_datetime(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except Exception: + return None + + +def _format_datetime(dt: Optional[datetime], fallback: str = "") -> tuple[str, int]: + if not dt: + return (fallback or "", 0) + try: + dt = dt.astimezone(timezone.utc).replace(microsecond=0) + return (dt.isoformat().replace("+00:00", "Z"), int(dt.timestamp())) + except Exception: + return (fallback or "", 0) + + +def _extract_email_address(value: str) -> str: + """从 `Name ` 中提取 addr;解析失败则原样返回。""" + try: + _name, addr = parseaddr(str(value or "")) + return addr or str(value or "") + except Exception: + return str(value or "") + + +def _build_message_summary(email_addr: str, item: Dict[str, Any], *, method: str) -> Dict[str, Any]: + raw_from = item.get("from") + if isinstance(raw_from, dict): + from_address = (raw_from.get("emailAddress") or {}).get("address") or "" + else: + from_address = str(raw_from or item.get("from_address") or "") + from_address = _extract_email_address(from_address) + + subject = str(item.get("subject") or "无主题") + + created_at_raw = ( + item.get("receivedDateTime") + or item.get("date") + or item.get("created_at") + or item.get("received_at") + or "" + ) + created_dt = _parse_datetime(str(created_at_raw)) + created_at, timestamp = _format_datetime(created_dt, str(created_at_raw)) + + content_preview = str( + item.get("bodyPreview") + or item.get("body_preview") + or item.get("content_preview") + or item.get("bodyPreview") + or "" + ) + + is_read = bool(item.get("isRead") if "isRead" in item else item.get("is_read") or item.get("isRead") or False) + + return { + "id": str(item.get("id") or ""), + "email_address": email_addr, + "from_address": from_address, + "subject": subject, + "content_preview": content_preview, + "has_html": bool(item.get("has_html") or False), + "timestamp": timestamp, + "created_at": created_at, + "is_read": is_read, + "method": method, + } + + +def _get_proxy_url(account: Dict[str, Any]) -> str: + proxy_url = "" + group_id = account.get("group_id") + if not group_id: + return "" + group = groups_repo.get_group_by_id(group_id) + if group: + proxy_url = group.get("proxy_url", "") or "" + return proxy_url + + +def require_account(email_addr: str) -> Dict[str, Any]: + email_addr = (email_addr or "").strip() + if not email_addr: + raise InvalidParamError("email 参数不能为空") + if "@" not in email_addr: + raise InvalidParamError("email 参数无效") + account = accounts_repo.get_account_by_email(email_addr) + if not account: + raise AccountNotFoundError("账号不存在", data={"email": email_addr}) + return account + + +def list_messages_for_external( + *, + email_addr: str, + folder: str = "inbox", + skip: int = 0, + top: int = 20, +) -> Tuple[List[Dict[str, Any]], str]: + account = require_account(email_addr) + folder = (folder or "inbox").strip().lower() or "inbox" + skip = max(0, int(skip or 0)) + top = max(1, min(int(top or 20), 50)) + + account_type = (account.get("account_type") or "outlook").strip().lower() + if account_type == "imap": + result = get_emails_imap_generic( + email_addr=email_addr, + imap_password=account.get("imap_password", "") or "", + imap_host=account.get("imap_host", "") or "", + imap_port=account.get("imap_port", 993) or 993, + folder=folder, + provider=account.get("provider", "_default") or "_default", + skip=skip, + top=top, + ) + if not result.get("success"): + raise UpstreamReadFailedError("IMAP 读取失败", data=result.get("error")) + method_label = str(result.get("method") or "IMAP (Generic)") + emails = [_build_message_summary(email_addr, e, method=method_label) for e in (result.get("emails") or [])] + return emails, method_label + + proxy_url = _get_proxy_url(account) + + graph_result = graph_service.get_emails_graph( + account.get("client_id") or "", + account.get("refresh_token") or "", + folder=folder, + skip=skip, + top=top, + proxy_url=proxy_url, + ) + if graph_result.get("success"): + method_label = "Graph API" + emails = [_build_message_summary(email_addr, e, method=method_label) for e in (graph_result.get("emails") or [])] + return emails, method_label + + graph_error = graph_result.get("error") + if isinstance(graph_error, dict) and graph_error.get("type") in ("ProxyError", "ConnectionError"): + raise ProxyError("代理连接失败", data=graph_error) + + # Graph 失败 → IMAP(New) → IMAP(Old) 回退 + imap_new_result = imap_service.get_emails_imap_with_server( + email_addr, + account.get("client_id") or "", + account.get("refresh_token") or "", + folder, + skip, + top, + IMAP_SERVER_NEW, + ) + if imap_new_result.get("success"): + method_label = "IMAP (New)" + emails = [_build_message_summary(email_addr, e, method=method_label) for e in (imap_new_result.get("emails") or [])] + return emails, method_label + + imap_old_result = imap_service.get_emails_imap_with_server( + email_addr, + account.get("client_id") or "", + account.get("refresh_token") or "", + folder, + skip, + top, + IMAP_SERVER_OLD, + ) + if imap_old_result.get("success"): + method_label = "IMAP (Old)" + emails = [_build_message_summary(email_addr, e, method=method_label) for e in (imap_old_result.get("emails") or [])] + return emails, method_label + + raise UpstreamReadFailedError( + "Graph/IMAP 均读取失败", + data={"graph": graph_error, "imap_new": imap_new_result.get("error"), "imap_old": imap_old_result.get("error")}, + ) + + +def filter_messages( + emails: List[Dict[str, Any]], + *, + from_contains: str = "", + subject_contains: str = "", + since_minutes: Optional[int] = None, +) -> List[Dict[str, Any]]: + from_contains = (from_contains or "").strip().lower() + subject_contains = (subject_contains or "").strip().lower() + + since_dt: Optional[datetime] = None + if since_minutes is not None: + try: + since_minutes_int = int(since_minutes) + if since_minutes_int > 0: + since_dt = _utcnow() - timedelta(minutes=since_minutes_int) + except Exception: + since_dt = None + + filtered: List[Dict[str, Any]] = [] + for e in emails or []: + from_addr = str(e.get("from_address") or e.get("from") or "").lower() + subj = str(e.get("subject") or "").lower() + if from_contains and from_contains not in from_addr: + continue + if subject_contains and subject_contains not in subj: + continue + + if since_dt is not None: + dt = _parse_datetime(e.get("created_at") or e.get("date") or e.get("receivedDateTime") or "") + if dt and dt < since_dt: + continue + + filtered.append(e) + return filtered + + +def get_latest_message_for_external( + *, + email_addr: str, + folder: str = "inbox", + from_contains: str = "", + subject_contains: str = "", + since_minutes: Optional[int] = None, +) -> Dict[str, Any]: + emails = list_messages_for_external(email_addr=email_addr, folder=folder, skip=0, top=20)[0] + filtered = filter_messages( + emails, + from_contains=from_contains, + subject_contains=subject_contains, + since_minutes=since_minutes, + ) + if not filtered: + raise MailNotFoundError("未找到匹配邮件", data={"email": email_addr}) + # 保险起见按 timestamp 再排序一次(不同读取链路可能不严格有序) + filtered.sort(key=lambda x: int(x.get("timestamp") or 0), reverse=True) + return filtered[0] + + +def get_message_detail_for_external( + *, + email_addr: str, + message_id: str, + folder: str = "inbox", +) -> Dict[str, Any]: + account = require_account(email_addr) + message_id = (message_id or "").strip() + if not message_id: + raise InvalidParamError("message_id 不能为空") + + folder = (folder or "inbox").strip().lower() or "inbox" + account_type = (account.get("account_type") or "outlook").strip().lower() + + if account_type == "imap": + detail = get_email_detail_imap_generic( + email_addr=email_addr, + imap_password=account.get("imap_password", "") or "", + imap_host=account.get("imap_host", "") or "", + imap_port=account.get("imap_port", 993) or 993, + message_id=message_id, + folder=folder, + provider=account.get("provider", "_default") or "_default", + ) + if not detail: + raise MailNotFoundError("未找到邮件详情", data={"email": email_addr, "message_id": message_id}) + + html_content = str(detail.get("body_html") or "") + content = str(detail.get("body_text") or "") or extract_email_text({"body_html": html_content}) + raw_content = str(detail.get("raw_content") or "") + created_at_raw = str(detail.get("date") or "") + created_at, timestamp = _format_datetime(_parse_datetime(created_at_raw), created_at_raw) + return { + "id": detail.get("id") or message_id, + "email_address": email_addr, + "from_address": _extract_email_address(detail.get("from") or ""), + "to_address": detail.get("to") or "", + "subject": detail.get("subject") or "", + "content": content, + "html_content": html_content, + "raw_content": raw_content, + "timestamp": timestamp, + "created_at": created_at, + "has_html": bool(html_content), + "method": "IMAP (Generic)", + } + + proxy_url = _get_proxy_url(account) + + detail = graph_service.get_email_detail_graph( + account.get("client_id") or "", + account.get("refresh_token") or "", + message_id, + proxy_url, + ) + method_label = "Graph API" + if not detail: + detail = imap_service.get_email_detail_imap_with_server( + email_addr, + account.get("client_id") or "", + account.get("refresh_token") or "", + message_id, + folder, + IMAP_SERVER_NEW, + ) + method_label = "IMAP (New)" + + if not detail: + detail = imap_service.get_email_detail_imap_with_server( + email_addr, + account.get("client_id") or "", + account.get("refresh_token") or "", + message_id, + folder, + IMAP_SERVER_OLD, + ) + method_label = "IMAP (Old)" + + if not detail: + raise MailNotFoundError("未找到邮件详情", data={"email": email_addr, "message_id": message_id}) + + created_at_raw = "" + timestamp = 0 + created_at = "" + + if "body" in detail and isinstance(detail.get("body"), dict): + body_obj = detail.get("body") or {} + body_type = str(body_obj.get("contentType") or "text").lower() + body_content = str(body_obj.get("content") or "") + + html_content = body_content if body_type == "html" else "" + content = body_content if body_type == "text" else extract_email_text({"body_html": html_content}) + raw_content = body_content + + from_address = (detail.get("from") or {}).get("emailAddress", {}).get("address", "") + to_address = ",".join([r.get("emailAddress", {}).get("address", "") for r in (detail.get("toRecipients") or [])]) + created_at_raw = str(detail.get("receivedDateTime") or "") + subject = str(detail.get("subject") or "") + else: + # IMAP dict 格式 + content = str(detail.get("body") or "") + html_content = "" + raw_content = str(detail.get("raw_content") or content) + from_address = _extract_email_address(str(detail.get("from") or "")) + to_address = str(detail.get("to") or "") + created_at_raw = str(detail.get("date") or "") + subject = str(detail.get("subject") or "") + + created_at, timestamp = _format_datetime(_parse_datetime(created_at_raw), created_at_raw) + + return { + "id": message_id, + "email_address": email_addr, + "from_address": _extract_email_address(from_address), + "to_address": to_address, + "subject": subject, + "content": content, + "html_content": html_content, + "raw_content": raw_content, + "timestamp": timestamp, + "created_at": created_at, + "has_html": bool(html_content), + "method": method_label, + } + + +def get_verification_result( + *, + email_addr: str, + folder: str = "inbox", + from_contains: str = "", + subject_contains: str = "", + since_minutes: Optional[int] = None, + code_regex: str | None = None, + code_length: str | None = None, + code_source: str = "all", +) -> Dict[str, Any]: + latest_summary = get_latest_message_for_external( + email_addr=email_addr, + folder=folder, + from_contains=from_contains, + subject_contains=subject_contains, + since_minutes=since_minutes, + ) + message_id = str(latest_summary.get("id") or "") + method = str(latest_summary.get("method") or "") + + detail = get_message_detail_for_external(email_addr=email_addr, message_id=message_id, folder=folder) + + email_obj = { + "subject": detail.get("subject") or "", + "body": detail.get("content") or "", + "body_html": detail.get("html_content") or "", + "body_preview": latest_summary.get("content_preview") or "", + } + extracted = extract_verification_info_with_options( + email_obj, + code_regex=code_regex, + code_length=code_length, + code_source=code_source, + ) + extracted["email"] = email_addr + extracted["matched_email_id"] = message_id + extracted["from"] = detail.get("from_address") or latest_summary.get("from_address") or "" + extracted["subject"] = detail.get("subject") or latest_summary.get("subject") or "" + extracted["received_at"] = detail.get("created_at") or latest_summary.get("created_at") or "" + extracted["method"] = detail.get("method") or method + return extracted + + +def wait_for_message( + *, + email_addr: str, + timeout_seconds: int = 30, + poll_interval: int = 5, + folder: str = "inbox", + from_contains: str = "", + subject_contains: str = "", + since_minutes: Optional[int] = None, +) -> Dict[str, Any]: + try: + timeout_seconds = int(timeout_seconds) + poll_interval = int(poll_interval) + except Exception as exc: + raise InvalidParamError("timeout_seconds/poll_interval 参数无效") from exc + + if timeout_seconds <= 0 or timeout_seconds > MAX_TIMEOUT_SECONDS: + raise InvalidParamError(f"timeout_seconds 必须在 1-{MAX_TIMEOUT_SECONDS} 秒之间") + if poll_interval <= 0 or poll_interval > timeout_seconds: + raise InvalidParamError("poll_interval 参数无效") + + start = time.time() + last_error: Optional[ExternalApiError] = None + while True: + try: + return get_latest_message_for_external( + email_addr=email_addr, + folder=folder, + from_contains=from_contains, + subject_contains=subject_contains, + since_minutes=since_minutes, + ) + except MailNotFoundError as exc: + last_error = exc + + if time.time() - start >= timeout_seconds: + raise MailNotFoundError("等待超时,未检测到匹配邮件", data={"email": email_addr}) from last_error + + time.sleep(poll_interval) + + +def audit_external_api_access( + *, + action: str, + email_addr: str, + endpoint: str, + status: str, + details: Dict[str, Any] | None = None, +): + safe_details: Dict[str, Any] = {"endpoint": endpoint, "status": status} + if details: + # 避免日志中输出敏感信息(如 API Key) + safe_details.update(details) + + try: + details_text = json.dumps(safe_details, ensure_ascii=False) + except Exception: + details_text = str(safe_details) + + log_audit(action, "external_api", email_addr, details_text) diff --git a/outlook_web/services/imap.py b/outlook_web/services/imap.py index 389f9fd..38bc89b 100644 --- a/outlook_web/services/imap.py +++ b/outlook_web/services/imap.py @@ -309,14 +309,26 @@ def get_email_detail_imap( message_id: str, folder: str = "inbox", ) -> Optional[Dict]: - """使用 IMAP 获取邮件详情""" + """使用 IMAP 获取邮件详情(默认使用新版服务器)。""" + return get_email_detail_imap_with_server(account, client_id, refresh_token, message_id, folder, IMAP_SERVER_NEW) + + +def get_email_detail_imap_with_server( + account: str, + client_id: str, + refresh_token: str, + message_id: str, + folder: str = "inbox", + server: str = IMAP_SERVER_NEW, +) -> Optional[Dict]: + """使用 IMAP 获取邮件详情(支持指定服务器)。""" access_token = get_access_token_imap(client_id, refresh_token) if not access_token: return None connection = None try: - connection = imaplib.IMAP4_SSL(IMAP_SERVER_NEW, IMAP_PORT) + connection = imaplib.IMAP4_SSL(server, IMAP_PORT) auth_string = f"user={account}\1auth=Bearer {access_token}\1\1".encode("utf-8") connection.authenticate("XOAUTH2", lambda x: auth_string) @@ -361,6 +373,12 @@ def get_email_detail_imap( raw_email = msg_data[0][1] msg = email.message_from_bytes(raw_email) + raw_text = "" + try: + raw_text = raw_email.decode("utf-8", errors="replace") if isinstance(raw_email, (bytes, bytearray)) else "" + except Exception: + raw_text = "" + return { "id": message_id, "subject": decode_header_value(msg.get("Subject", "无主题")), @@ -369,6 +387,7 @@ def get_email_detail_imap( "cc": decode_header_value(msg.get("Cc", "")), "date": msg.get("Date", "未知时间"), "body": get_email_body(msg), + "raw_content": raw_text, } except Exception: return None diff --git a/outlook_web/services/imap_generic.py b/outlook_web/services/imap_generic.py index 6538401..004b051 100644 --- a/outlook_web/services/imap_generic.py +++ b/outlook_web/services/imap_generic.py @@ -426,6 +426,15 @@ def get_email_detail_imap_generic( msg = email.message_from_bytes(raw_email) body_text, body_html = _extract_text_and_html(msg) + raw_content = "" + try: + if isinstance(raw_email, (bytes, bytearray)): + raw_content = raw_email.decode("utf-8", errors="replace") + else: + raw_content = str(raw_email) + except Exception: + raw_content = "" + detail: Dict[str, Any] = { "id": message_id, "subject": decode_header_value(msg.get("Subject", "无主题")), @@ -437,6 +446,7 @@ def get_email_detail_imap_generic( "has_attachments": _has_attachments(msg), "body_text": body_text or "", "body_html": body_html or "", + "raw_content": raw_content, } # 兼容现有前端/验证码提取:提供 body/body_type diff --git a/outlook_web/services/verification_extractor.py b/outlook_web/services/verification_extractor.py index 0f7fd74..9b1846c 100644 --- a/outlook_web/services/verification_extractor.py +++ b/outlook_web/services/verification_extractor.py @@ -40,6 +40,15 @@ VERIFICATION_PATTERN = r"\b[A-Z0-9]{4,8}\b" # 链接正则表达式 LINK_PATTERN = r'https?://[^\s<>"{}|\\^`\[\]]+' +# 对外/参数化提取:验证链接优先关键词 +DEFAULT_LINK_KEYWORDS = [ + "verify", + "confirmation", + "confirm", + "activate", + "validation", +] + class HTMLTextExtractor(HTMLParser): """HTML 转纯文本提取器""" @@ -323,3 +332,214 @@ def extract_verification_info(email: Dict[str, Any]) -> Dict[str, Any]: raise ValueError("未找到验证信息") return result + + +def _extract_content_text_without_subject(email: Dict[str, Any]) -> str: + """ + 仅提取邮件正文/预览(不回退到 subject)。 + + 注意:extract_email_text() 会在末尾回退 subject;该函数用于 code_source=content 时严格遵循语义。 + """ + # 优先使用纯文本 + if email.get("body") and str(email["body"]).strip(): + return str(email["body"]).strip() + + # 其次使用 HTML 转纯文本 + if email.get("body_html") and str(email["body_html"]).strip(): + parser = HTMLTextExtractor() + try: + parser.feed(str(email["body_html"])) + text = html.unescape(parser.get_text() or "") + return text.strip() + except Exception: + return "" + + # Graph API 格式 bodyContent/bodyContentType + if email.get("bodyContent") and str(email["bodyContent"]).strip(): + content = str(email["bodyContent"]) + if str(email.get("bodyContentType") or "").lower() == "html": + parser = HTMLTextExtractor() + try: + parser.feed(content) + text = html.unescape(parser.get_text() or "") + return text.strip() + except Exception: + return "" + return content.strip() + + if email.get("body_preview") and str(email["body_preview"]).strip(): + return str(email["body_preview"]).strip() + + return "" + + +def _parse_code_length(code_length: str) -> tuple[int, int]: + m = re.match(r"^(\d+)-(\d+)$", str(code_length or "").strip()) + if not m: + raise ValueError("code_length 参数无效") + min_len = int(m.group(1)) + max_len = int(m.group(2)) + if min_len <= 0 or max_len <= 0 or min_len > max_len: + raise ValueError("code_length 参数无效") + return min_len, max_len + + +def _build_code_regex(*, code_regex: str | None, code_length: str | None) -> re.Pattern: + if code_regex: + try: + return re.compile(code_regex) + except re.error as exc: + raise ValueError("code_regex 参数无效") from exc + + if code_length: + min_len, max_len = _parse_code_length(code_length) + return re.compile(rf"\b\d{{{min_len},{max_len}}}\b") + + # 默认:4-8 位数字验证码(更贴近“验证码”场景) + return re.compile(r"\b\d{4,8}\b") + + +def _smart_extract_code_by_keywords(email_content: str, code_re: re.Pattern) -> Optional[str]: + if not email_content: + return None + + content_lower = email_content.lower() + + for keyword in VERIFICATION_KEYWORDS: + keyword_lower = keyword.lower() + pos = content_lower.find(keyword_lower) + if pos == -1: + continue + + start = max(0, pos - 50) + end = min(len(email_content), pos + len(keyword) + 50) + context = email_content[start:end] + + for m in code_re.finditer(context): + value = m.group(0) + if value and any(c.isdigit() for c in value): + return value.upper() + + return None + + +def _fallback_extract_code(email_content: str, code_re: re.Pattern) -> Optional[str]: + if not email_content: + return None + + candidates: List[str] = [] + for m in code_re.finditer(email_content): + value = m.group(0) or "" + if not value: + continue + + # 与现有提取器保持一致:必须包含至少一个数字 + if not any(c.isdigit() for c in value): + continue + + # 过滤常见误判(仅对纯数字候选生效) + if value.isdigit() and len(value) == 4: + year = int(value) + if 1900 <= year <= 2100: + continue + hour = int(value[:2]) + minute = int(value[2:]) + if 0 <= hour <= 23 and 0 <= minute <= 59: + continue + num = int(value) + if 2020 <= num <= 2030: + continue + + candidates.append(value.upper()) + + return candidates[0] if candidates else None + + +def _pick_preferred_link(links: List[str], prefer_link_keywords: List[str]) -> Optional[str]: + if not links: + return None + + keywords = [k.lower() for k in (prefer_link_keywords or []) if k] + if keywords: + for keyword in keywords: + for link in links: + if keyword in (link or "").lower(): + return link + + return links[0] + + +def extract_verification_info_with_options( + email: Dict[str, Any], + *, + code_regex: str | None = None, + code_length: str | None = None, + code_source: str = "all", + prefer_link_keywords: list[str] | None = None, +) -> Dict[str, Any]: + """ + 在现有提取器基础上支持: + - 自定义验证码正则(code_regex) + - 自定义验证码长度范围(code_length,如 4-8 / 6-6) + - 提取来源限定(subject/content/html/all) + - 验证链接关键词优先级(prefer_link_keywords) + + 返回结构为兼容扩展字典: + - verification_code + - verification_link + - links + - formatted + - match_source + - confidence + + 注意:该函数主要服务外部 API,不主动抛“未找到验证码/链接”的异常,方便上层按需映射错误码。 + """ + subject = str(email.get("subject") or "").strip() + content = _extract_content_text_without_subject(email) + html_content = str(email.get("body_html") or email.get("html_content") or "").strip() + + source = str(code_source or "all").strip().lower() + if source == "subject": + source_text = subject + match_source = "subject" + elif source == "content": + source_text = content + match_source = "content" + elif source == "html": + source_text = html_content + match_source = "html" + else: + source_text = f"{subject} {content} {html_content}".strip() + match_source = "all" + + code_re = _build_code_regex(code_regex=code_regex, code_length=code_length) + + verification_code = _smart_extract_code_by_keywords(source_text, code_re) + confidence = "high" if verification_code else "low" + if not verification_code: + verification_code = _fallback_extract_code(source_text, code_re) + + links = extract_links(f"{subject} {content} {html_content}".strip()) + prefer_keywords = prefer_link_keywords or DEFAULT_LINK_KEYWORDS + verification_link = _pick_preferred_link(links, prefer_keywords) + if verification_link and confidence != "high": + for kw in prefer_keywords: + if kw and kw.lower() in verification_link.lower(): + confidence = "high" + break + + parts: List[str] = [] + if verification_code: + parts.append(verification_code) + if verification_link: + parts.append(verification_link) + formatted = " ".join(parts) if parts else None + + return { + "verification_code": verification_code, + "verification_link": verification_link, + "links": links, + "formatted": formatted, + "match_source": match_source, + "confidence": confidence, + } diff --git a/static/js/main.js b/static/js/main.js index 8a13350..4de4506 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -973,11 +973,36 @@ ${details} const data = await response.json(); if (data.success) { - // 密码不显示,只显示 API Key - document.getElementById('settingsApiKey').value = data.settings.gptmail_api_key || ''; - // 密码框留空 + // 密码不回显 document.getElementById('settingsPassword').value = ''; + // GPTMail API Key(仅脱敏展示,避免回填明文) + const gptmailApiKeyEl = document.getElementById('settingsApiKey'); + if (gptmailApiKeyEl) { + const maskedValue = data.settings.gptmail_api_key_masked || ''; + gptmailApiKeyEl.value = maskedValue; + gptmailApiKeyEl.dataset.maskedValue = maskedValue; + gptmailApiKeyEl.dataset.isSet = data.settings.gptmail_api_key_set ? 'true' : 'false'; + } + + // 对外开放 API Key(仅脱敏展示,避免回填明文) + const externalApiKeyEl = document.getElementById('settingsExternalApiKey'); + if (externalApiKeyEl) { + const maskedValue = data.settings.external_api_key_masked || ''; + externalApiKeyEl.value = maskedValue; + externalApiKeyEl.dataset.maskedValue = maskedValue; + externalApiKeyEl.dataset.isSet = data.settings.external_api_key_set ? 'true' : 'false'; + } + + const externalHintEl = document.getElementById('externalApiKeyHint'); + if (externalHintEl) { + if (data.settings.external_api_key_set) { + externalHintEl.textContent = `已设置:${data.settings.external_api_key_masked || ''}(请求头:X-API-Key;清空后保存可禁用对外接口)`; + } else { + externalHintEl.textContent = '未设置(设置后可通过 /api/external/* 对外开放接口读取邮件与验证码)'; + } + } + // 加载刷新配置 document.getElementById('refreshIntervalDays').value = data.settings.refresh_interval_days || '30'; document.getElementById('refreshDelaySeconds').value = data.settings.refresh_delay_seconds || '5'; @@ -1071,7 +1096,17 @@ ${details} // 保存设置 async function saveSettings() { const password = document.getElementById('settingsPassword').value; - const apiKey = document.getElementById('settingsApiKey').value.trim(); + + const gptmailApiKeyEl = document.getElementById('settingsApiKey'); + const gptmailApiKey = gptmailApiKeyEl ? gptmailApiKeyEl.value.trim() : ''; + const gptmailApiKeyMasked = gptmailApiKeyEl ? (gptmailApiKeyEl.dataset.maskedValue || '') : ''; + const gptmailApiKeyIsSet = gptmailApiKeyEl ? gptmailApiKeyEl.dataset.isSet === 'true' : false; + + const externalApiKeyEl = document.getElementById('settingsExternalApiKey'); + const externalApiKey = externalApiKeyEl ? externalApiKeyEl.value.trim() : ''; + const externalApiKeyMasked = externalApiKeyEl ? (externalApiKeyEl.dataset.maskedValue || '') : ''; + const externalApiKeyIsSet = externalApiKeyEl ? externalApiKeyEl.dataset.isSet === 'true' : false; + const refreshDays = document.getElementById('refreshIntervalDays').value; const refreshDelay = document.getElementById('refreshDelaySeconds').value; const refreshCron = document.getElementById('refreshCron').value.trim(); @@ -1090,8 +1125,15 @@ ${details} settings.login_password = password; } - // API Key 可以为空(清除) - settings.gptmail_api_key = apiKey; + // GPTMail API Key:仅当用户真实输入时才覆盖(避免把脱敏占位符写回 DB) + if (!(gptmailApiKeyIsSet && gptmailApiKey && gptmailApiKey === gptmailApiKeyMasked)) { + settings.gptmail_api_key = gptmailApiKey; + } + + // 对外开放 API Key:允许清空(空字符串) + if (!(externalApiKeyIsSet && externalApiKey && externalApiKey === externalApiKeyMasked)) { + settings.external_api_key = externalApiKey; + } // 刷新配置 const days = parseInt(refreshDays); diff --git a/templates/index.html b/templates/index.html index f3efacf..068b311 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,481 +1,488 @@ - - - - + + + + Outlook 邮件管理 - + - - -
- - - - - - - -
- -
-
- -
-
仪表盘
-
系统概览
-
-
-
-
- - - - - -
-
-
-
总账号数
-
-
-
-
-
Token 有效
-
-
-
-
-
Token 过期
-
-
-
-
-
临时邮箱
-
-
-
-
- -
-
-
-
📂 分组概览
-
-
-
    -
  • 加载中…
  • -
-
-
-
-
-
🔄 最近刷新
-
-
-
    -
  • 加载中…
  • -
-
-
-
-
- - -
-
- -
-
- 分组 - -
- -
-
加载中…
-
-
- - -
- - -
-
- - - 选择分组 - -
- - - -
-
- -
- - - -
- - -
-
- -
-
- 📁 -

请从左侧选择一个分组

-
-
- -
- - -
- - -
-
-
- - - -
-
- -
-
- - -
-
- 📬 -

请从左侧选择一个邮箱账号

-
-
- - - -
-
-
- - -
-
- -
-
- ⚡ 临时邮箱 - -
-
-
- 📭 -

暂无临时邮箱

- -
-
-
- -
-
- 选择一个临时邮箱 -
- -
-
-
-
- 📬 -

选择一个临时邮箱查看邮件

-
-
-
-
-
- - -
-
-
-
🔄 刷新日志
-
-
-
加载中…
-
-
-
- - -
-
-
-
⚙️ 系统设置
-
-
- + + +
+ + + + + + + +
+ +
+
+ +
+
仪表盘
+
系统概览
+
+
+
+
+ + + + + +
+
+
+
总账号数
+
-
+
+
+
Token 有效
+
-
+
+
+
Token 过期
+
-
+
+
+
临时邮箱
+
-
+
+
+ +
+
+
+
📂 分组概览
+
+
+
    +
  • 加载中…
  • +
+
+
+
+
+
🔄 最近刷新
+
+
+
    +
  • 加载中…
  • +
+
+
+
+
+ + +
+
+ +
+
+ 分组 + +
+ +
+
加载中…
+
+
+ + +
+ + +
+
+ + + 选择分组 + +
+ + + +
+
+ +
+ + + +
+ + +
+
+ +
+
+ 📁 +

请从左侧选择一个分组

+
+
+ +
+ + +
+ + +
+
+
+ + + +
+
+ +
+
+ + +
+
+ 📬 +

请从左侧选择一个邮箱账号

+
+
+ + + +
+
+
+ + +
+
+ +
+
+ ⚡ 临时邮箱 + +
+
+
+ 📭 +

暂无临时邮箱

+ +
+
+
+ +
+
+ 选择一个临时邮箱 +
+ +
+
+
+
+ 📬 +

选择一个临时邮箱查看邮件

+
+
+
+
+
+ + +
+
+
+
🔄 刷新日志
+
+
+
加载中…
+
+
+
+ + +
+
+
+
⚙️ 系统设置
+
+
+ +
+ + +
用于登录系统的密码
+
+
+ + +
用于临时邮箱功能,可从 gptmail.io 获取
+
- - -
用于登录系统的密码
-
-
- - -
用于临时邮箱功能,可从 gptmail.io 获取
+ + +
+ 用于对外开放接口鉴权(请求头:X-API-Key)。如需禁用对外开放接口,可清空后保存。 +

🔄 Token 刷新设置

- -
关闭后将不会自动执行定时刷新任务
-
-
- -
- - -
-
-
-
- -
- - 天 -
-
建议设置为 30 天,防止 Token 因 90 天不使用而过期
-
-
- -
- -
- - 秒 -
-
建议设置为 5-10 秒,避免频繁请求触发 API 限流
-
-
- - -
-

🔔 自动轮询设置

-
- -
开启后自动检查当前账号是否有新邮件
-
-
- -
- - 秒 -
-
范围:5-300 秒,建议设置为 10-30 秒
-
-
- -
- - 次 -
-
范围:1-100 次,设置为 0 表示持续轮询
-
-
- - -
-

📬 Telegram 推送

-
- - -
格式:1234567890:AAxxxxxx(留空则禁用推送)
-
-
- - -
可用 @userinfobot 获取你的 Chat ID
-
-
- -
- - 秒 -
-
范围:60-3600 秒,默认 600 秒(10 分钟)
-
-
- -
验证 Bot Token 和 Chat ID 是否配置正确
-
-
- - -
- -
-
-
-
- - -
-
-
-
📋 审计日志
-
-
-
加载中…
-
-
-
-
-
- - -
- - {% include 'partials/modals.html' %} - - {% include 'partials/scripts.html' %} - - - + +
关闭后将不会自动执行定时刷新任务
+
+
+ +
+ + +
+
+
+
+ +
+ + 天 +
+
建议设置为 30 天,防止 Token 因 90 天不使用而过期
+
+
+ +
+ +
+ + 秒 +
+
建议设置为 5-10 秒,避免频繁请求触发 API 限流
+
+
+ + +
+

🔔 自动轮询设置

+
+ +
开启后自动检查当前账号是否有新邮件
+
+
+ +
+ + 秒 +
+
范围:5-300 秒,建议设置为 10-30 秒
+
+
+ +
+ + 次 +
+
范围:1-100 次,设置为 0 表示持续轮询
+
+
+ + +
+

📬 Telegram 推送

+
+ + +
格式:1234567890:AAxxxxxx(留空则禁用推送)
+
+
+ + +
可用 @userinfobot 获取你的 Chat ID
+
+
+ +
+ + 秒 +
+
范围:60-3600 秒,默认 600 秒(10 分钟)
+
+
+ +
验证 Bot Token 和 Chat ID 是否配置正确
+
+
+ + +
+ +
+
+
+
+ + +
+
+
+
📋 审计日志
+
+
+
加载中…
+
+
+
+ + + + +
+ + {% include 'partials/modals.html' %} + + {% include 'partials/scripts.html' %} + + + diff --git a/tests/test_external_api.py b/tests/test_external_api.py new file mode 100644 index 0000000..cb70826 --- /dev/null +++ b/tests/test_external_api.py @@ -0,0 +1,370 @@ +import json +import unittest +import uuid +from unittest.mock import patch + +from tests._import_app import clear_login_attempts, import_web_app_module + + +class ExternalApiBaseTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + cls.app = cls.module.app + + def setUp(self): + with self.app.app_context(): + clear_login_attempts() + from outlook_web.db import get_db + from outlook_web.repositories import settings as settings_repo + + db = get_db() + db.execute("DELETE FROM audit_logs WHERE resource_type = 'external_api'") + db.execute("DELETE FROM accounts WHERE email LIKE '%@extapi.test'") + db.commit() + settings_repo.set_setting("external_api_key", "") + + def _set_external_api_key(self, value: str): + with self.app.app_context(): + from outlook_web.repositories import settings as settings_repo + + settings_repo.set_setting("external_api_key", value) + + def _insert_outlook_account(self, email_addr: str | None = None) -> str: + email_addr = email_addr or f"{uuid.uuid4().hex}@extapi.test" + with self.app.app_context(): + from outlook_web.db import get_db + + db = get_db() + db.execute( + """ + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, status, account_type, provider) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (email_addr, "pw", "cid-test", "rt-test", 1, "active", "outlook", "outlook"), + ) + db.commit() + return email_addr + + def _insert_imap_account(self, email_addr: str | None = None) -> str: + email_addr = email_addr or f"{uuid.uuid4().hex}@extapi.test" + with self.app.app_context(): + from outlook_web.db import get_db + + db = get_db() + db.execute( + """ + INSERT INTO accounts ( + email, password, client_id, refresh_token, group_id, status, + account_type, provider, imap_host, imap_port, imap_password + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (email_addr, "pw", "cid-test", "rt-test", 1, "active", "imap", "custom", "imap.test.com", 993, "imap-pass"), + ) + db.commit() + return email_addr + + @staticmethod + def _auth_headers(value: str = "abc123"): + return {"X-API-Key": value} + + def _login(self, client, password: str = "testpass123"): + resp = client.post("/login", json={"password": password}) + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json().get("success")) + + @staticmethod + def _graph_email(message_id: str = "msg-1", subject: str = "Your verification code", sender: str = "noreply@example.com"): + return { + "id": message_id, + "subject": subject, + "from": {"emailAddress": {"address": sender}}, + "receivedDateTime": "2026-03-08T12:00:00Z", + "isRead": False, + "hasAttachments": False, + "bodyPreview": "Your code is 123456", + } + + @staticmethod + def _graph_detail(message_id: str = "msg-1", body_text: str = "Your code is 123456", html_text: str = "

Your code is 123456

"): + return { + "id": message_id, + "subject": "Your verification code", + "from": {"emailAddress": {"address": "noreply@example.com"}}, + "toRecipients": [{"emailAddress": {"address": "user@outlook.com"}}], + "receivedDateTime": "2026-03-08T12:00:00Z", + "body": {"content": body_text if body_text else html_text, "contentType": "text" if body_text else "html"}, + } + + +class ExternalApiAuthTests(ExternalApiBaseTest): + def test_external_health_requires_api_key(self): + client = self.app.test_client() + self._set_external_api_key("abc123") + + resp = client.get("/api/external/health") + + self.assertEqual(resp.status_code, 401) + data = resp.get_json() + self.assertEqual(data.get("code"), "UNAUTHORIZED") + + def test_external_health_returns_403_when_api_key_not_configured(self): + client = self.app.test_client() + + resp = client.get("/api/external/health", headers=self._auth_headers("abc123")) + + self.assertEqual(resp.status_code, 403) + data = resp.get_json() + self.assertEqual(data.get("code"), "API_KEY_NOT_CONFIGURED") + + def test_external_health_accepts_valid_api_key(self): + client = self.app.test_client() + self._set_external_api_key("abc123") + + resp = client.get("/api/external/health", headers=self._auth_headers("abc123")) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertEqual(data.get("code"), "OK") + + +class ExternalApiMessageTests(ExternalApiBaseTest): + def test_external_messages_returns_account_not_found(self): + client = self.app.test_client() + self._set_external_api_key("abc123") + + resp = client.get( + "/api/external/messages?email=missing@extapi.test", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.get_json().get("code"), "ACCOUNT_NOT_FOUND") + + @patch("outlook_web.services.graph.get_emails_graph") + def test_external_messages_returns_list_when_graph_succeeds(self, mock_get_emails_graph): + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_get_emails_graph.return_value = {"success": True, "emails": [self._graph_email()]} + + client = self.app.test_client() + resp = client.get( + f"/api/external/messages?email={email_addr}", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertEqual(data.get("code"), "OK") + self.assertEqual(len(data.get("data", {}).get("emails", [])), 1) + + @patch("outlook_web.services.imap.get_emails_imap_with_server") + @patch("outlook_web.services.graph.get_emails_graph") + def test_external_messages_falls_back_to_imap_when_graph_fails(self, mock_get_emails_graph, mock_get_emails_imap): + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_get_emails_graph.return_value = {"success": False, "error": "graph failed"} + mock_get_emails_imap.return_value = { + "success": True, + "emails": [ + { + "id": "imap-1", + "subject": "IMAP Subject", + "from": "imap@example.com", + "date": "2026-03-08T12:00:00Z", + "is_read": False, + "has_attachments": False, + "body_preview": "preview", + } + ], + } + + client = self.app.test_client() + resp = client.get( + f"/api/external/messages?email={email_addr}", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertEqual(len(data.get("data", {}).get("emails", [])), 1) + + @patch("outlook_web.services.graph.get_email_detail_graph") + def test_external_message_detail_returns_message_content(self, mock_get_email_detail_graph): + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_get_email_detail_graph.return_value = self._graph_detail() + + client = self.app.test_client() + resp = client.get( + f"/api/external/messages/msg-1?email={email_addr}", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertIn("content", data.get("data", {})) + self.assertIn("raw_content", data.get("data", {})) + + @patch("outlook_web.services.graph.get_email_detail_graph") + def test_external_message_raw_returns_raw_content(self, mock_get_email_detail_graph): + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_get_email_detail_graph.return_value = self._graph_detail(body_text="raw test") + + client = self.app.test_client() + resp = client.get( + f"/api/external/messages/msg-1/raw?email={email_addr}", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertIn("raw_content", data.get("data", {})) + + +class ExternalApiVerificationTests(ExternalApiBaseTest): + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_external_verification_code_returns_code(self, mock_get_emails_graph, mock_get_email_detail_graph): + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_get_emails_graph.return_value = {"success": True, "emails": [self._graph_email()]} + mock_get_email_detail_graph.return_value = self._graph_detail(body_text="Your code is 123456") + + client = self.app.test_client() + resp = client.get( + f"/api/external/verification-code?email={email_addr}", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertEqual(data.get("data", {}).get("verification_code"), "123456") + + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_external_verification_link_returns_preferred_link(self, mock_get_emails_graph, mock_get_email_detail_graph): + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_get_emails_graph.return_value = { + "success": True, + "emails": [self._graph_email(subject="Please verify your email")], + } + mock_get_email_detail_graph.return_value = self._graph_detail( + body_text="Click https://example.com/verify?token=abc to continue", + ) + + client = self.app.test_client() + resp = client.get( + f"/api/external/verification-link?email={email_addr}", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertIn("verify", data.get("data", {}).get("verification_link", "")) + + def test_external_wait_message_rejects_too_large_timeout(self): + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + client = self.app.test_client() + + resp = client.get( + f"/api/external/wait-message?email={email_addr}&timeout_seconds=999", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 400) + self.assertEqual(resp.get_json().get("code"), "INVALID_PARAM") + + +class ExternalApiSystemTests(ExternalApiBaseTest): + def test_external_capabilities_returns_feature_list(self): + client = self.app.test_client() + self._set_external_api_key("abc123") + + resp = client.get("/api/external/capabilities", headers=self._auth_headers()) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertIn("service", data.get("data", {})) + self.assertIn("version", data.get("data", {})) + self.assertIn("features", data.get("data", {})) + + def test_external_account_status_returns_account_data(self): + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + client = self.app.test_client() + + resp = client.get( + f"/api/external/account-status?email={email_addr}", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertEqual(data.get("data", {}).get("email"), email_addr) + self.assertTrue(data.get("data", {}).get("exists")) + self.assertEqual(data.get("data", {}).get("account_type"), "outlook") + self.assertEqual(data.get("data", {}).get("provider"), "outlook") + self.assertIn("preferred_method", data.get("data", {})) + self.assertIn("last_refresh_at", data.get("data", {})) + self.assertTrue(data.get("data", {}).get("can_read")) + + +class ExternalApiRegressionTests(ExternalApiBaseTest): + @patch("outlook_web.services.graph.get_emails_graph") + def test_internal_email_list_api_still_works(self, mock_get_emails_graph): + email_addr = self._insert_outlook_account() + mock_get_emails_graph.return_value = {"success": True, "emails": [self._graph_email()]} + + client = self.app.test_client() + self._login(client) + resp = client.get(f"/api/emails/{email_addr}") + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertIn("emails", data) + + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_internal_extract_verification_api_still_works(self, mock_get_emails_graph, mock_get_email_detail_graph): + email_addr = self._insert_outlook_account() + mock_get_emails_graph.return_value = {"success": True, "emails": [self._graph_email()]} + mock_get_email_detail_graph.return_value = self._graph_detail(body_text="Your code is 123456") + + client = self.app.test_client() + self._login(client) + resp = client.get(f"/api/emails/{email_addr}/extract-verification") + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertEqual(data.get("data", {}).get("verification_code"), "123456") + + def test_internal_settings_api_still_returns_existing_fields(self): + client = self.app.test_client() + self._login(client) + + resp = client.get("/api/settings") + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertIn("refresh_interval_days", data.get("settings", {})) + self.assertIn("gptmail_api_key_set", data.get("settings", {})) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_settings_external_api_key.py b/tests/test_settings_external_api_key.py new file mode 100644 index 0000000..e945966 --- /dev/null +++ b/tests/test_settings_external_api_key.py @@ -0,0 +1,96 @@ +import unittest + +from tests._import_app import clear_login_attempts, import_web_app_module + + +class ExternalApiKeySettingsTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + cls.app = cls.module.app + + def setUp(self): + with self.app.app_context(): + clear_login_attempts() + from outlook_web.repositories import settings as settings_repo + + settings_repo.set_setting("external_api_key", "") + + def _login(self, client, password: str = "testpass123"): + resp = client.post("/login", json={"password": password}) + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json().get("success")) + + def test_get_settings_exposes_external_api_key_status_and_masked_value(self): + with self.app.app_context(): + from outlook_web.repositories import settings as settings_repo + + settings_repo.set_setting("external_api_key", "abcdef1234567890") + + client = self.app.test_client() + self._login(client) + resp = client.get("/api/settings") + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + settings = data.get("settings", {}) + self.assertIn("external_api_key_set", settings) + self.assertIn("external_api_key_masked", settings) + self.assertTrue(settings.get("external_api_key_set")) + self.assertNotEqual(settings.get("external_api_key_masked"), "abcdef1234567890") + + def test_put_settings_can_update_external_api_key(self): + client = self.app.test_client() + self._login(client) + + resp = client.put("/api/settings", json={"external_api_key": "new-key-123"}) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + + resp2 = client.get("/api/settings") + self.assertEqual(resp2.status_code, 200) + settings = resp2.get_json().get("settings", {}) + self.assertTrue(settings.get("external_api_key_set")) + + def test_clearing_external_api_key_marks_open_api_as_not_configured(self): + client = self.app.test_client() + self._login(client) + + resp = client.put("/api/settings", json={"external_api_key": ""}) + + self.assertEqual(resp.status_code, 200) + resp2 = client.get("/api/settings") + settings = resp2.get_json().get("settings", {}) + self.assertFalse(settings.get("external_api_key_set")) + + def test_put_settings_does_not_overwrite_when_sending_masked_placeholder(self): + original = "abcdef1234567890" + with self.app.app_context(): + from outlook_web.repositories import settings as settings_repo + + settings_repo.set_setting("external_api_key", original) + + client = self.app.test_client() + self._login(client) + + resp1 = client.get("/api/settings") + self.assertEqual(resp1.status_code, 200) + masked = resp1.get_json().get("settings", {}).get("external_api_key_masked") + self.assertTrue(masked) + self.assertNotEqual(masked, original) + + resp2 = client.put("/api/settings", json={"external_api_key": masked}) + self.assertEqual(resp2.status_code, 200) + self.assertTrue(resp2.get_json().get("success")) + + with self.app.app_context(): + from outlook_web.repositories import settings as settings_repo + + self.assertEqual(settings_repo.get_external_api_key(), original) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ui_settings_external_api_key.py b/tests/test_ui_settings_external_api_key.py new file mode 100644 index 0000000..9d8b9b4 --- /dev/null +++ b/tests/test_ui_settings_external_api_key.py @@ -0,0 +1,40 @@ +import unittest + +from tests._import_app import import_web_app_module + + +class ExternalApiKeySettingsUITests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + cls.app = cls.module.app + + def _login(self, client, password: str = "testpass123"): + resp = client.post("/login", json={"password": password}) + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json().get("success")) + + def test_settings_page_has_external_api_key_input(self): + client = self.app.test_client() + self._login(client) + + resp = client.get("/") + self.assertEqual(resp.status_code, 200) + html = resp.data.decode("utf-8") + self.assertIn('id="settingsExternalApiKey"', html) + + def test_main_js_loads_masked_external_api_key_fields(self): + client = self.app.test_client() + self._login(client) + + resp = client.get("/static/js/main.js") + self.assertEqual(resp.status_code, 200) + js = resp.data.decode("utf-8") + self.assertIn("external_api_key_masked", js) + self.assertIn("settingsExternalApiKey", js) + self.assertIn("dataset.maskedValue", js) + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_verification_extractor_options.py b/tests/test_verification_extractor_options.py new file mode 100644 index 0000000..85f905a --- /dev/null +++ b/tests/test_verification_extractor_options.py @@ -0,0 +1,74 @@ +import unittest + +from outlook_web.services import verification_extractor as extractor + + +class VerificationExtractorOptionsTests(unittest.TestCase): + def _require_new_api(self): + func = getattr(extractor, "extract_verification_info_with_options", None) + self.assertTrue(callable(func), "缺少 extract_verification_info_with_options()") + return func + + def test_extract_with_default_options_returns_code(self): + func = self._require_new_api() + email = { + "subject": "Your verification code", + "body": "Your code is 123456", + "body_html": "

Your code is 123456

", + } + + result = func(email) + + self.assertEqual(result.get("verification_code"), "123456") + + def test_extract_with_code_length_prefers_specified_length(self): + func = self._require_new_api() + email = { + "subject": "Your code", + "body": "short 1234 and target 654321", + "body_html": "", + } + + result = func(email, code_length="6-6") + + self.assertEqual(result.get("verification_code"), "654321") + + def test_extract_with_code_regex_supports_alphanumeric_code(self): + func = self._require_new_api() + email = { + "subject": "OTP", + "body": "Use AB12CD to continue", + "body_html": "", + } + + result = func(email, code_regex=r"\b[A-Z0-9]{6}\b") + + self.assertEqual(result.get("verification_code"), "AB12CD") + + def test_extract_with_code_source_subject_only(self): + func = self._require_new_api() + email = { + "subject": "Code 778899", + "body": "no code here", + "body_html": "", + } + + result = func(email, code_source="subject") + + self.assertEqual(result.get("verification_code"), "778899") + + def test_extract_with_preferred_link_keywords_returns_verify_link_first(self): + func = self._require_new_api() + email = { + "subject": "Please verify your email", + "body": "Open https://example.com/home or https://example.com/verify?token=abc", + "body_html": "", + } + + result = func(email) + + self.assertIn("verify", result.get("verification_link", "")) + + +if __name__ == "__main__": + unittest.main()