From 09c3ab3416a958d1630d6b95d93b54a5952fb756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E7=BB=8D=E5=BA=B7?= <3052147989@qq.com> Date: Thu, 12 Mar 2026 22:19:43 +0800 Subject: [PATCH] fix: gate low-confidence verification extraction results as 404 (BUG-00017) - Add code_confidence/link_confidence fine-grained fields to extractor - Service-layer credibility gate: confidence != high -> nullify result -> 404 - Caller-directed extraction (code_regex only) gets high confidence - Narrow LINK_CONTEXT_PHRASES with object nouns (email/account) prevent false positives - 31 new tests (21 unit + 10 integration), 376 total passing - Update docs/api.md with new fields, null semantics, v1.5 gating notes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/api.md | 130 +++-- outlook_web/services/external_api.py | 239 ++++++++ .../services/verification_extractor.py | 64 ++- tests/test_external_api.py | 539 +++++++++++++++++- tests/test_verification_extractor_options.py | 259 +++++++++ 5 files changed, 1179 insertions(+), 52 deletions(-) diff --git a/docs/api.md b/docs/api.md index 8362604..7db643a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,7 +8,7 @@ - 本地化部署 - 单实例 -- 单可信调用方 +- 多调用方受控接入 - 内网或受控访问环境 当前版本不建议直接公网暴露。原因见: @@ -41,12 +41,12 @@ ### 1.2 配套但不是新建开放接口的后台接口 -以下接口用于管理员配置 `external_api_key`,属于现有后台接口扩展,不属于开放接口本身: +以下接口用于管理员配置对外 API 鉴权模型,属于现有后台接口扩展,不属于开放接口本身: | 方法 | 路径 | 登录要求 | 说明 | |------|------|----------|------| -| `GET` | `/api/settings` | 需要后台登录 | 返回 `external_api_key_set`、`external_api_key_masked` | -| `PUT` | `/api/settings` | 需要后台登录 | 保存或清空 `external_api_key` | +| `GET` | `/api/settings` | 需要后台登录 | 返回 legacy `external_api_key_*` 字段,以及 `external_api_keys` 多 Key 列表与当日调用统计 | +| `PUT` | `/api/settings` | 需要后台登录 | 保存或清空 legacy `external_api_key`,或整体替换 `external_api_keys` 多 Key 配置 | ### 1.3 当前不建议继续新增的接口 @@ -55,9 +55,7 @@ P0 阶段不建议再新增以下业务接口: - 邮件删除、标记已读、移动邮件等写接口 - 附件下载、附件元数据开放接口 - Webhook / 回调通知接口 -- 多调用方 API Key 管理接口 - -原因是当前目标是“受控私有接入闭环”,不是开放平台化扩张。 +原因是当前目标仍是“受控私有接入闭环”,不是开放平台化扩张。 ### 1.4 后续阶段可能补充的能力 @@ -68,7 +66,7 @@ P0 阶段不建议再新增以下业务接口: | P1 | 公网模式、IP 白名单、限流 | 已实现(v1.1) | 安全守卫层 `external_api_guard.py` | | P1 | 高风险接口禁用(raw/wait-message) | 已实现(v1.1) | 设置页可动态开关 | | P2 | `wait-message` 异步探测解耦 | 已实现(v1.2) | `mode=async` + `/api/external/probe/` | -| P2 | 多 API Key / 范围授权 | 否 | 优先落在设置与鉴权模型,不急于新增公开路径 | +| P2 | 多 API Key / 范围授权 / 调用方审计 | 已实现(v1.4) | 仍落在设置与鉴权模型,不新增公开路径 | 结论: @@ -103,7 +101,19 @@ X-API-Key: your-api-key - 仅支持 Header 中的 `X-API-Key` - 不支持 query 参数中的 `api_key` -- 未配置 `external_api_key` 时,统一返回 `403 API_KEY_NOT_CONFIGURED` +- 未配置 legacy `external_api_key` 且未配置任何启用中的 `external_api_keys` 时,统一返回 `403 API_KEY_NOT_CONFIGURED` + +### 2.2.2 P2 多 API Key 与邮箱范围授权(v1.4 新增) + +- 支持同时配置多个 `external_api_keys` +- 每个 Key 可配置: + - `name` + - `enabled` + - `allowed_emails` +- `allowed_emails=[]` 表示该 Key 不限制邮箱范围 +- `allowed_emails` 非空时,仅允许访问名单中的邮箱 +- 使用超出范围的邮箱时,统一返回 `403 EMAIL_SCOPE_FORBIDDEN` +- legacy `external_api_key` 仍保留兼容;未迁移前可继续使用 ### 2.2.1 P1 公网安全层(v1.1 新增) @@ -150,8 +160,8 @@ X-API-Key: your-api-key | `INVALID_PARAM` | `400` | 参数不合法 | | `ACCOUNT_NOT_FOUND` | `404` | 指定邮箱账号不存在 | | `MAIL_NOT_FOUND` | `404` | 未找到匹配邮件 | -| `VERIFICATION_CODE_NOT_FOUND` | `404` | 未找到验证码 | -| `VERIFICATION_LINK_NOT_FOUND` | `404` | 未找到验证链接 | +| `VERIFICATION_CODE_NOT_FOUND` | `404` | 未找到可信验证码(含低置信度被拦截的场景) | +| `VERIFICATION_LINK_NOT_FOUND` | `404` | 未找到可信验证链接(含低置信度被拦截的场景) | | `PROXY_ERROR` | `502` | 代理连接失败 | | `UPSTREAM_READ_FAILED` | `502` | Graph / IMAP 均读取失败 | | `INTERNAL_ERROR` | `500` | 服务内部错误 | @@ -376,7 +386,7 @@ curl -H "X-API-Key: your-api-key" \ > > `/api/external/messages/{message_id}/raw` 返回未经过滤的邮件原始内容(完整 MIME 正文),可能包含敏感信息、附件二进制数据或恶意脚本。 > -> **当前版本定位**:仅面向受控私有接入场景(单实例、单可信调用方、内网部署)。 +> **当前版本定位**:仅面向受控私有接入场景(单实例、多调用方受控接入、内网部署)。 > > **风险点**: > - 原始内容无字段级脱敏,调用方须自行处理展示安全 @@ -400,6 +410,9 @@ curl -H "X-API-Key: your-api-key" \ 特别说明: - 未显式传入 `since_minutes` 时,该接口默认只扫描最近 `10` 分钟的邮件 +- **置信度门控(v1.5)**:仅当验证码通过关键词智能提取命中(`code_confidence=high`)时才视为成功。 + 若邮件中无验证码关键词上下文、仅 fallback 匹配到普通数字(`code_confidence=low`),接口将返回 `404 VERIFICATION_CODE_NOT_FOUND`,而非此前的 `200 OK`。 + 调用方可通过 `from_contains`、`subject_contains`、`code_regex` 等参数缩窄匹配范围,提升命中准确率。 成功响应示例: @@ -410,11 +423,13 @@ curl -H "X-API-Key: your-api-key" \ "message": "success", "data": { "verification_code": "123456", - "verification_link": "", + "verification_link": null, "links": [], "formatted": "123456", "match_source": "content", "confidence": "high", + "code_confidence": "high", + "link_confidence": "low", "email": "user@outlook.com", "matched_email_id": "msg-1", "from": "noreply@example.com", @@ -425,10 +440,20 @@ curl -H "X-API-Key: your-api-key" \ } ``` +> 注:低置信度字段在成功响应中会被置为 `null`(非空字符串)。仅高置信度命中的字段会返回实际值。 + +响应字段说明: + +| 字段 | 说明 | +|------|------| +| `confidence` | 向后兼容总置信度(`code_confidence` 与 `link_confidence` 中较高者) | +| `code_confidence` | 验证码置信度:`high`=关键词上下文命中或调用方指定 `code_regex` 精确匹配命中;`low`=仅 fallback 扫描命中或未命中 | +| `link_confidence` | 验证链接置信度:`high`=链接 URL 含验证关键词(verify/confirm/activate/validation)或邮件正文含强验证语境短语(需带对象名词如 email/account);`low`=任意首链接且无验证语境 | + 失败场景: - 没有命中邮件:`404 MAIL_NOT_FOUND` -- 命中邮件但未提取到验证码:`404 VERIFICATION_CODE_NOT_FOUND` +- 命中邮件但未提取到**可信**验证码:`404 VERIFICATION_CODE_NOT_FOUND` ### 5.2 `GET /api/external/verification-link` @@ -437,6 +462,8 @@ curl -H "X-API-Key: your-api-key" \ 特别说明: - 未显式传入 `since_minutes` 时,该接口默认只扫描最近 `10` 分钟的邮件 +- **置信度门控(v1.5)**:仅当链接 URL 包含验证关键词(verify/confirm/activate/validation),或邮件正文/主题含强验证语境短语(如 "verify your email"、"confirm your account"、"activate your account"、"邮箱验证" 等带对象名词的完整短语)时才视为成功(`link_confidence=high`)。 + 若邮件中仅包含普通跳转链接且无验证语境(`link_confidence=low`),接口将返回 `404 VERIFICATION_LINK_NOT_FOUND`。 成功响应示例: @@ -446,13 +473,15 @@ curl -H "X-API-Key: your-api-key" \ "code": "OK", "message": "success", "data": { - "verification_code": "", + "verification_code": null, "verification_link": "https://example.com/verify?token=abc", "links": [ "https://example.com/verify?token=abc" ], "formatted": "https://example.com/verify?token=abc", "confidence": "high", + "code_confidence": "low", + "link_confidence": "high", "email": "user@outlook.com", "matched_email_id": "msg-1", "from": "noreply@example.com", @@ -466,7 +495,7 @@ curl -H "X-API-Key: your-api-key" \ 失败场景: - 没有命中邮件:`404 MAIL_NOT_FOUND` -- 命中邮件但未提取到验证链接:`404 VERIFICATION_LINK_NOT_FOUND` +- 命中邮件但未提取到**可信**验证链接:`404 VERIFICATION_LINK_NOT_FOUND` ### 5.3 `GET /api/external/wait-message` @@ -578,15 +607,18 @@ curl -H "X-API-Key: your-api-key" \ "service": "outlook-email-plus", "version": "0.1.0-draft", "server_time_utc": "2026-03-08T12:00:00Z", - "database": "ok" + "database": "ok", + "upstream_probe_ok": true, + "last_probe_at": "2026-03-12T12:00:00Z", + "last_probe_error": "" } } ``` 注意: -- 该接口当前偏向“服务与数据库存活” -- 不代表上游 Graph / IMAP 一定真实可读 +- 该接口仍以“服务与数据库存活”为主 +- 但会补充返回实例级最近一次真实上游读取探测摘要 ### 6.2 `GET /api/external/capabilities` @@ -640,7 +672,11 @@ curl -H "X-API-Key: your-api-key" \ "status": "active", "last_refresh_at": null, "preferred_method": "graph", - "can_read": true + "can_read": true, + "upstream_probe_ok": true, + "probe_method": "Graph API", + "last_probe_at": "2026-03-12T12:00:00Z", + "last_probe_error": "" } } ``` @@ -648,7 +684,7 @@ curl -H "X-API-Key: your-api-key" \ 注意: - `can_read=true` 仅表示基础配置字段存在 -- 不等于已经完成真实拉信探测 +- `upstream_probe_ok` 才表示最近一次真实拉信探测结果 ## 七、管理员配置接口说明 @@ -662,6 +698,9 @@ curl -H "X-API-Key: your-api-key" \ |------|------| | `external_api_key_set` | 是否已配置开放 API Key | | `external_api_key_masked` | 脱敏后的 Key 展示值 | +| `external_api_keys` | 多 API Key 列表(含 `id`、`name`、`enabled`、`allowed_emails`、`api_key_masked`、当日调用统计) | +| `external_api_keys_count` | 多 API Key 数量 | +| `external_api_multi_key_set` | 是否已配置多 API Key | ### 7.2 `PUT /api/settings` @@ -672,17 +711,21 @@ curl -H "X-API-Key: your-api-key" \ | 字段 | 类型 | 说明 | |------|------|------| | `external_api_key` | string | 设置新的开放 API Key;传空字符串表示清空 | +| `external_api_keys` | array | 整体替换多 API Key 配置;元素支持 `id`、`name`、`api_key`、`enabled`、`allowed_emails` | 说明: - 配置写入后不会明文回显 - 实际存储会走现有敏感配置加密逻辑 +- `PUT /api/settings` 采用事务式提交;当请求中任一设置项校验失败时,本次所有变更都会回滚 +- `external_api_keys` 采用“全量替换”语义,删除某个 Key 时不再提交该项即可 +- 更新已有 Key 时可继续提交 `api_key_masked` 占位值,后端会保留原 Key ## 八、接入建议 推荐接入顺序: -1. 管理员登录后台,通过 `/api/settings` 配置 `external_api_key` +1. 管理员登录后台,通过 `/api/settings` 配置 legacy `external_api_key` 或 `external_api_keys` 2. 调用 `/api/external/health` 检查基础可用性 3. 调用 `/api/external/account-status?email=...` 确认目标邮箱存在 4. 视业务选择 `/messages`、`/verification-code`、`/verification-link` @@ -691,9 +734,9 @@ curl -H "X-API-Key: your-api-key" \ ## 九、已知限制 - 当前仅适合受控私有接入,不建议直接公网暴露 -- 当前只有单 `external_api_key`,默认具备读取本实例全部已配置邮箱的能力边界 - `wait-message` 为同步轮询实现,不适合高并发公网场景 - `/api/external/health` 与 `/api/external/account-status` 当前仍偏轻量,不等价于真实上游探测 +- 设置页前端已提供 `external_api_keys` 的 JSON 文本编辑入口,可直接维护多 Key、启停状态和邮箱范围授权 ## 十、当前接口问题与处理口径 @@ -719,21 +762,21 @@ curl -H "X-API-Key: your-api-key" \ - P0/P1 不为此单独新增查询接口 - 如未来确实需要全局消息索引,再考虑在新版本中调整 -### 10.2 `health` 与 `account-status` 当前是轻量自检,不是真实探测 +### 10.2 `health` 与 `account-status` 当前是“轻量自检 + 最近探测摘要” 现状: -- `/api/external/health` 主要反映服务和数据库是否存活 -- `/api/external/account-status` 主要反映账号是否存在、基础配置是否完整 +- `/api/external/health` 主要反映服务和数据库是否存活,并返回实例级最近探测摘要 +- `/api/external/account-status` 主要反映账号是否存在、基础配置是否完整,并返回该账号最近探测摘要 问题: -- 它们不能证明 Graph / IMAP 在当前时刻一定可读 +- 它们返回的是“最近一次探测结果”,不是持续实时健康流 处理口径: -- 当前文档明确按“轻量自检”解释这两个接口 -- P1 优先扩展字段,不优先新增同类接口 +- 当前文档明确按“轻量自检 + 最近探测摘要”解释这两个接口 +- 如需更强诊断能力,再单独演进 probe 服务 ### 10.3 `/raw` 接口敏感度高 @@ -768,42 +811,44 @@ curl -H "X-API-Key: your-api-key" \ - P1 若进入公网模式,应优先禁用或限制 - P2 再考虑是否替换为异步模型 -### 10.5 单 API Key 权限边界较粗 +### 10.5 多 API Key 已支持,但配额仍以审计统计为主 现状: -- 当前只有一个 `external_api_key` -- 默认可读取本实例全部已配置邮箱 +- 当前已支持多个 `external_api_keys` +- 每个 Key 可限制 `allowed_emails` +- 审计日志已记录 `consumer_id` / `consumer_name` +- `/api/settings` 已返回调用方当日聚合统计 问题: -- 不适合多调用方或复杂权限边界 +- 目前仍未实现“超阈值即拒绝”的硬性调用方限额拦截 +- 暂无独立开发者门户或专门配额管理页面 处理口径: -- 当前仅按单可信调用方场景验收 -- P2 再做多 API Key 和范围授权 +- 当前已满足多调用方受控接入与基础配额审计 +- 如需硬性 quota / 套餐化能力,再单独扩展平台层 ## 十一、后续阶段接口文档预留 -本节用于说明后续如果进入 P1/P2,接口文档应优先怎么演进。 +本节用于说明后续如果继续进入更深的 P1/P2,接口文档应优先怎么演进。 ### 11.1 P1:优先扩展现有接口字段 #### `GET /api/external/health` -建议后续增加但当前不承诺的字段: +当前已实现字段: | 字段 | 含义 | |------|------| | `upstream_probe_ok` | 上游读取链路最近一次探测是否成功 | | `last_probe_at` | 最近一次探测时间 | | `last_probe_error` | 最近一次探测错误摘要 | -| `public_mode` | 当前是否处于公网模式 | #### `GET /api/external/capabilities` -建议后续增加但当前不承诺的字段: +当前已实现字段: | 字段 | 含义 | |------|------| @@ -813,7 +858,7 @@ curl -H "X-API-Key: your-api-key" \ #### `GET /api/external/account-status` -建议后续增加但当前不承诺的字段: +当前已实现字段: | 字段 | 含义 | |------|------| @@ -849,8 +894,7 @@ P1 更推荐补的是控制能力,而不是新增更多邮件接口: 当前不提前承诺新增以下路径: -- 异步等待接口 -- 多调用方管理接口 -- 配额管理接口 +- 更细粒度的开发者门户接口 +- 硬性 quota 套餐化管理接口 如果未来必须新增,建议进入新版本或新增专门的管理域,而不是继续把所有能力堆在 `/api/external/*` 主业务路径下。 diff --git a/outlook_web/services/external_api.py b/outlook_web/services/external_api.py index db7499e..19fa08f 100644 --- a/outlook_web/services/external_api.py +++ b/outlook_web/services/external_api.py @@ -8,7 +8,9 @@ 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 external_api_keys as external_api_keys_repo from outlook_web.repositories import groups as groups_repo +from outlook_web.security.auth import get_external_api_consumer 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 @@ -67,6 +69,11 @@ class UpstreamReadFailedError(ExternalApiError): status = 502 +class EmailScopeForbiddenError(ExternalApiError): + code = "EMAIL_SCOPE_FORBIDDEN" + status = 403 + + def ok(data: Any = None, *, message: str = "success") -> Dict[str, Any]: return {"success": True, "code": "OK", "message": message, "data": data} @@ -126,6 +133,25 @@ def _extract_email_address(value: str) -> str: return str(value or "") +def get_current_external_api_consumer() -> Dict[str, Any]: + return get_external_api_consumer() or {} + + +def ensure_external_email_access(email_addr: str) -> None: + consumer = get_current_external_api_consumer() + allowed_emails = [str(item or "").strip().lower() for item in (consumer.get("allowed_emails") or [])] + target_email = str(email_addr or "").strip().lower() + if allowed_emails and target_email not in allowed_emails: + raise EmailScopeForbiddenError( + "当前 API Key 无权访问该邮箱", + data={ + "email": email_addr, + "consumer_id": consumer.get("id"), + "consumer_name": consumer.get("name"), + }, + ) + + 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): @@ -185,6 +211,187 @@ def require_account(email_addr: str) -> Dict[str, Any]: return account +def _preferred_probe_method(account: Dict[str, Any]) -> str: + account_type = (account.get("account_type") or "outlook").strip().lower() + return "imap_generic" if account_type == "imap" else "graph" + + +def _account_can_read(account: Dict[str, Any]) -> bool: + status = (account.get("status") or "active").strip().lower() + if status == "disabled": + return False + account_type = (account.get("account_type") or "outlook").strip().lower() + if account_type == "imap": + return bool((account.get("imap_host") or "").strip()) and bool((account.get("imap_password") or "").strip()) + return bool((account.get("client_id") or "").strip()) and bool((account.get("refresh_token") or "").strip()) + + +def _probe_now_iso() -> str: + return _utcnow().replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _probe_summary_from_row(row: Any) -> Dict[str, Any]: + if not row: + return { + "upstream_probe_ok": None, + "probe_method": "", + "last_probe_at": "", + "last_probe_error": "", + } + return { + "upstream_probe_ok": None if row["probe_ok"] is None else bool(row["probe_ok"]), + "probe_method": row["probe_method"] or "", + "last_probe_at": row["last_probe_at"] or "", + "last_probe_error": row["last_probe_error"] or "", + } + + +def get_upstream_probe_summary(scope_type: str, scope_key: str) -> Dict[str, Any]: + from outlook_web.db import get_db + + db = get_db() + row = db.execute( + """ + SELECT scope_type, scope_key, email_addr, probe_method, probe_ok, last_probe_at, last_probe_error + FROM external_upstream_probes + WHERE scope_type = ? AND scope_key = ? + """, + (scope_type, scope_key), + ).fetchone() + return _probe_summary_from_row(row) + + +def _is_probe_summary_fresh(summary: Dict[str, Any], cache_ttl_seconds: int) -> bool: + last_probe_at = summary.get("last_probe_at") or "" + if not last_probe_at: + return False + probed_at = _parse_datetime(last_probe_at) + if not probed_at: + return False + age_seconds = (_utcnow() - probed_at).total_seconds() + return age_seconds <= max(0, int(cache_ttl_seconds)) + + +def record_upstream_probe_summary( + *, + scope_type: str, + scope_key: str, + email_addr: str, + probe_ok: Optional[bool], + probe_method: str = "", + last_probe_error: str = "", + last_probe_at: Optional[str] = None, +) -> Dict[str, Any]: + from outlook_web.db import get_db + + db = get_db() + probe_at = last_probe_at or _probe_now_iso() + db.execute( + """ + INSERT INTO external_upstream_probes + (scope_type, scope_key, email_addr, probe_method, probe_ok, last_probe_at, last_probe_error, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(scope_type, scope_key) + DO UPDATE SET + email_addr = excluded.email_addr, + probe_method = excluded.probe_method, + probe_ok = excluded.probe_ok, + last_probe_at = excluded.last_probe_at, + last_probe_error = excluded.last_probe_error, + updated_at = CURRENT_TIMESTAMP + """, + ( + scope_type, + scope_key, + email_addr or "", + probe_method or "", + None if probe_ok is None else int(bool(probe_ok)), + probe_at, + str(last_probe_error or "")[:500], + ), + ) + db.commit() + return { + "upstream_probe_ok": probe_ok, + "probe_method": probe_method or "", + "last_probe_at": probe_at, + "last_probe_error": str(last_probe_error or "")[:500], + } + + +def _probe_error_message(exc: Exception) -> str: + if isinstance(exc, ExternalApiError): + return str(exc.message or exc.code or "探测失败") + return str(exc)[:500] or type(exc).__name__ + + +def probe_account_upstream( + account: Dict[str, Any], + *, + folder: str = "inbox", + cache_ttl_seconds: int = 60, + force: bool = False, +) -> Dict[str, Any]: + email_addr = str(account.get("email") or "").strip() + preferred_method = _preferred_probe_method(account) + cached = get_upstream_probe_summary("account", email_addr) if email_addr else {} + if email_addr and (not force) and _is_probe_summary_fresh(cached, cache_ttl_seconds): + return cached + + last_probe_at = _probe_now_iso() + try: + _emails, method = list_messages_for_external(email_addr=email_addr, folder=folder, top=1, skip=0) + summary = record_upstream_probe_summary( + scope_type="account", + scope_key=email_addr, + email_addr=email_addr, + probe_ok=True, + probe_method=str(method or preferred_method), + last_probe_error="", + last_probe_at=last_probe_at, + ) + except Exception as exc: + summary = record_upstream_probe_summary( + scope_type="account", + scope_key=email_addr, + email_addr=email_addr, + probe_ok=False, + probe_method=preferred_method, + last_probe_error=_probe_error_message(exc), + last_probe_at=last_probe_at, + ) + record_upstream_probe_summary( + scope_type="instance", + scope_key="__instance__", + email_addr=email_addr, + probe_ok=summary.get("upstream_probe_ok"), + probe_method=summary.get("probe_method") or preferred_method, + last_probe_error=summary.get("last_probe_error") or "", + last_probe_at=summary.get("last_probe_at") or last_probe_at, + ) + return summary + + +def _pick_instance_probe_account() -> Optional[Dict[str, Any]]: + candidates = accounts_repo.load_accounts() + for account in candidates: + if _account_can_read(account): + return account + return None + + +def probe_instance_upstream(*, cache_ttl_seconds: int = 60, force: bool = False) -> Dict[str, Any]: + cached = get_upstream_probe_summary("instance", "__instance__") + if (not force) and (cached.get("last_probe_at") or ""): + return cached + + account = _pick_instance_probe_account() + if not account: + return cached + + return probe_account_upstream(account, cache_ttl_seconds=cache_ttl_seconds, force=force) + + def list_messages_for_external( *, email_addr: str, @@ -497,6 +704,16 @@ def get_verification_result( code_length=code_length, code_source=code_source, ) + + # ── 可信度门控:非高置信度结果不作为 external API 成功输出 ── + if extracted.get("code_confidence") != "high": + extracted["verification_code"] = None + if extracted.get("link_confidence") != "high": + extracted["verification_link"] = None + # 重算 formatted(与清零后的值保持一致) + parts = [v for v in (extracted.get("verification_code"), extracted.get("verification_link")) if v] + extracted["formatted"] = " ".join(parts) if parts else None + extracted["email"] = email_addr extracted["matched_email_id"] = message_id extracted["from"] = detail.get("from_address") or latest_summary.get("from_address") or "" @@ -802,6 +1019,18 @@ def audit_external_api_access( details: Dict[str, Any] | None = None, ): safe_details: Dict[str, Any] = {"endpoint": endpoint, "status": status} + consumer = get_current_external_api_consumer() + if consumer: + safe_details.update( + { + "consumer_id": consumer.get("id"), + "consumer_key": consumer.get("consumer_key"), + "consumer_name": consumer.get("name"), + "consumer_source": consumer.get("source"), + } + ) + if consumer.get("allowed_emails"): + safe_details["consumer_allowed_emails"] = consumer.get("allowed_emails") if details: # 避免日志中输出敏感信息(如 API Key) safe_details.update(details) @@ -812,3 +1041,13 @@ def audit_external_api_access( details_text = str(safe_details) log_audit(action, "external_api", email_addr, details_text) + try: + if consumer: + external_api_keys_repo.record_external_api_consumer_usage( + consumer_key=str(consumer.get("consumer_key") or ""), + consumer_name=str(consumer.get("name") or ""), + endpoint=endpoint, + status=status, + ) + except Exception: + pass diff --git a/outlook_web/services/verification_extractor.py b/outlook_web/services/verification_extractor.py index 9b1846c..f2318ba 100644 --- a/outlook_web/services/verification_extractor.py +++ b/outlook_web/services/verification_extractor.py @@ -49,6 +49,38 @@ DEFAULT_LINK_KEYWORDS = [ "validation", ] +# 链接语境提权:仅与"验证/激活账户/确认邮箱"强相关的完整短语 +# 必须带对象名词(email/account/邮箱/账户),避免 "confirm your order" 等交易邮件误触 +LINK_CONTEXT_PHRASES = [ + "verify your email", + "verify your account", + "verify your address", + "confirm your email", + "confirm your account", + "confirm your address", + "activate your email", + "activate your account", + "email verification", + "account verification", + "验证您的邮箱", + "验证你的邮箱", + "验证您的账户", + "验证你的账户", + "验证您的账号", + "验证你的账号", + "确认您的邮箱", + "确认你的邮箱", + "确认您的账户", + "确认你的账户", + "激活您的账户", + "激活你的账户", + "激活您的邮箱", + "激活你的邮箱", + "邮箱验证", + "账号验证", + "账户验证", +] + class HTMLTextExtractor(HTMLParser): """HTML 转纯文本提取器""" @@ -490,7 +522,9 @@ def extract_verification_info_with_options( - links - formatted - match_source - - confidence + - confidence (向后兼容:code/link 中较高者) + - code_confidence (high=关键词命中或调用方指定code_regex命中, low=仅 fallback 或未命中) + - link_confidence (high=链接含验证关键词或邮件正文含强验证语境短语, low=任意首链接且无验证语境) 注意:该函数主要服务外部 API,不主动抛“未找到验证码/链接”的异常,方便上层按需映射错误码。 """ @@ -513,20 +547,40 @@ def extract_verification_info_with_options( match_source = "all" code_re = _build_code_regex(code_regex=code_regex, code_length=code_length) + # 仅 code_regex 具有判别力,code_length 只是宽度约束,不自动提权 + caller_directed_code = bool(code_regex) + # ── 验证码提取 & 置信度 ── verification_code = _smart_extract_code_by_keywords(source_text, code_re) - confidence = "high" if verification_code else "low" + code_confidence: str = "high" if verification_code else "low" if not verification_code: verification_code = _fallback_extract_code(source_text, code_re) + # 调用方显式指定了 code_regex(强判别力正则)→ 提取命中即视为可信 + if verification_code and caller_directed_code: + code_confidence = "high" + # ── 链接提取 & 置信度 ── 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": + + link_confidence: str = "low" + if verification_link: + # 优先检查 URL 本身是否含验证关键词 for kw in prefer_keywords: if kw and kw.lower() in verification_link.lower(): - confidence = "high" + link_confidence = "high" break + # URL 不含关键词时,检查邮件正文/主题是否有强验证语境短语 + if link_confidence != "high": + full_text_lower = f"{subject} {content}".lower() + for phrase in LINK_CONTEXT_PHRASES: + if phrase.lower() in full_text_lower: + link_confidence = "high" + break + + # 总 confidence 向后兼容:取 code / link 中较高者 + confidence = "high" if code_confidence == "high" or link_confidence == "high" else "low" parts: List[str] = [] if verification_code: @@ -542,4 +596,6 @@ def extract_verification_info_with_options( "formatted": formatted, "match_source": match_source, "confidence": confidence, + "code_confidence": code_confidence, + "link_confidence": link_confidence, } diff --git a/tests/test_external_api.py b/tests/test_external_api.py index 174d59b..be2619d 100644 --- a/tests/test_external_api.py +++ b/tests/test_external_api.py @@ -22,6 +22,10 @@ class ExternalApiBaseTest(unittest.TestCase): 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.execute("DELETE FROM external_api_keys") + db.execute("DELETE FROM external_api_consumer_usage_daily") + db.execute("DELETE FROM external_upstream_probes") + db.execute("DELETE FROM external_probe_cache") db.commit() settings_repo.set_setting("external_api_key", "") @@ -31,6 +35,24 @@ class ExternalApiBaseTest(unittest.TestCase): settings_repo.set_setting("external_api_key", value) + def _create_external_api_key( + self, + name: str, + api_key: str, + *, + allowed_emails: list[str] | None = None, + enabled: bool = True, + ): + with self.app.app_context(): + from outlook_web.repositories import external_api_keys as external_api_keys_repo + + return external_api_keys_repo.create_external_api_key( + name=name, + api_key=api_key, + allowed_emails=allowed_emails or [], + enabled=enabled, + ) + 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(): @@ -127,6 +149,20 @@ class ExternalApiBaseTest(unittest.TestCase): """).fetchall() return [dict(row) for row in rows] + def _external_consumer_usage_rows(self): + with self.app.app_context(): + from outlook_web.db import get_db + + db = get_db() + rows = db.execute( + """ + SELECT consumer_key, consumer_name, endpoint, total_count, success_count, error_count + FROM external_api_consumer_usage_daily + ORDER BY id ASC + """ + ).fetchall() + return [dict(row) for row in rows] + class ExternalApiAuthTests(ExternalApiBaseTest): def test_external_health_requires_api_key(self): @@ -159,6 +195,44 @@ class ExternalApiAuthTests(ExternalApiBaseTest): self.assertTrue(data.get("success")) self.assertEqual(data.get("code"), "OK") + def test_external_health_accepts_valid_multi_api_key(self): + client = self.app.test_client() + self._create_external_api_key("partner-a", "multi-123") + + resp = client.get("/api/external/health", headers=self._auth_headers("multi-123")) + + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json().get("success")) + + def test_disabled_multi_api_key_rejected(self): + client = self.app.test_client() + self._create_external_api_key("partner-a", "multi-123", enabled=False) + + resp = client.get("/api/external/health", headers=self._auth_headers("multi-123")) + + self.assertEqual(resp.status_code, 403) + self.assertEqual(resp.get_json().get("code"), "API_KEY_NOT_CONFIGURED") + + def test_disabled_multi_api_key_returns_401_when_other_enabled_key_exists(self): + client = self.app.test_client() + self._create_external_api_key("partner-a", "multi-123", enabled=False) + self._create_external_api_key("partner-b", "multi-456", enabled=True) + + resp = client.get("/api/external/health", headers=self._auth_headers("multi-123")) + + self.assertEqual(resp.status_code, 401) + self.assertEqual(resp.get_json().get("code"), "UNAUTHORIZED") + + def test_legacy_external_api_key_still_works_when_multi_keys_exist(self): + client = self.app.test_client() + self._create_external_api_key("partner-a", "multi-123") + self._set_external_api_key("legacy-123") + + resp = client.get("/api/external/health", headers=self._auth_headers("legacy-123")) + + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json().get("success")) + class ExternalApiMessageTests(ExternalApiBaseTest): @patch("outlook_web.services.graph.get_emails_graph") @@ -210,6 +284,106 @@ class ExternalApiMessageTests(ExternalApiBaseTest): self.assertEqual(data.get("code"), "OK") self.assertEqual(len(data.get("data", {}).get("emails", [])), 1) + +class ExternalApiKeyScopeTests(ExternalApiBaseTest): + @patch("outlook_web.services.graph.get_emails_graph") + def test_external_messages_allows_email_within_key_scope(self, mock_get_emails_graph): + email_addr = self._insert_outlook_account() + self._create_external_api_key("partner-a", "scope-123", allowed_emails=[email_addr]) + 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("scope-123"), + ) + + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json().get("success")) + + def test_external_messages_reject_email_outside_key_scope(self): + allowed_email = self._insert_outlook_account() + denied_email = self._insert_outlook_account() + self._create_external_api_key("partner-a", "scope-123", allowed_emails=[allowed_email]) + + client = self.app.test_client() + resp = client.get( + f"/api/external/messages?email={denied_email}", + headers=self._auth_headers("scope-123"), + ) + + self.assertEqual(resp.status_code, 403) + self.assertEqual(resp.get_json().get("code"), "EMAIL_SCOPE_FORBIDDEN") + + def test_external_account_status_reject_email_outside_key_scope(self): + allowed_email = self._insert_outlook_account() + denied_email = self._insert_outlook_account() + self._create_external_api_key("partner-a", "scope-123", allowed_emails=[allowed_email]) + + client = self.app.test_client() + resp = client.get( + f"/api/external/account-status?email={denied_email}", + headers=self._auth_headers("scope-123"), + ) + + self.assertEqual(resp.status_code, 403) + self.assertEqual(resp.get_json().get("code"), "EMAIL_SCOPE_FORBIDDEN") + + def test_probe_status_rejects_email_outside_key_scope(self): + allowed_email = self._insert_outlook_account() + denied_email = self._insert_outlook_account() + self._create_external_api_key("partner-a", "scope-123", allowed_emails=[allowed_email]) + + with self.app.app_context(): + from outlook_web.db import get_db + + db = get_db() + now = datetime.now(timezone.utc).isoformat() + future = (datetime.now(timezone.utc) + timedelta(minutes=1)).isoformat() + db.execute( + """ + INSERT INTO external_probe_cache + (id, email_addr, status, timeout_seconds, poll_interval, expires_at, created_at, updated_at) + VALUES (?, ?, 'pending', 30, 5, ?, ?, ?) + """, + ("scope-probe-1", denied_email, future, now, now), + ) + db.commit() + + client = self.app.test_client() + resp = client.get("/api/external/probe/scope-probe-1", headers=self._auth_headers("scope-123")) + + self.assertEqual(resp.status_code, 403) + self.assertEqual(resp.get_json().get("code"), "EMAIL_SCOPE_FORBIDDEN") + + +class ExternalApiConsumerAuditTests(ExternalApiBaseTest): + @patch("outlook_web.services.graph.get_emails_graph") + def test_multi_key_request_records_consumer_metadata_and_usage(self, mock_get_emails_graph): + email_addr = self._insert_outlook_account() + created = self._create_external_api_key("partner-a", "audit-123", allowed_emails=[email_addr]) + 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("audit-123"), + ) + + self.assertEqual(resp.status_code, 200) + audit_logs = self._external_audit_logs() + self.assertEqual(len(audit_logs), 1) + self.assertIn('"consumer_name": "partner-a"', audit_logs[0]["details"]) + self.assertIn(f'"consumer_id": {created["id"]}', audit_logs[0]["details"]) + + usage_rows = self._external_consumer_usage_rows() + self.assertEqual(len(usage_rows), 1) + self.assertEqual(usage_rows[0]["consumer_key"], created["consumer_key"]) + self.assertEqual(usage_rows[0]["consumer_name"], "partner-a") + self.assertEqual(usage_rows[0]["endpoint"], "/api/external/messages") + self.assertEqual(usage_rows[0]["total_count"], 1) + self.assertEqual(usage_rows[0]["success_count"], 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): @@ -433,17 +607,34 @@ class ExternalApiSystemTests(ExternalApiBaseTest): def test_external_health_audits_access(self): client = self.app.test_client() self._set_external_api_key("abc123") + with self.app.app_context(): + from outlook_web.services import external_api as external_api_service + + external_api_service.record_upstream_probe_summary( + scope_type="instance", + scope_key="__instance__", + email_addr="probe@extapi.test", + probe_ok=True, + probe_method="Graph API", + last_probe_error="", + last_probe_at=self._utc_iso(), + ) resp = client.get("/api/external/health", headers=self._auth_headers()) self.assertEqual(resp.status_code, 200) + data = resp.get_json().get("data", {}) + self.assertTrue(data.get("upstream_probe_ok")) + self.assertTrue(data.get("last_probe_at")) audit_logs = self._external_audit_logs() self.assertEqual(len(audit_logs), 1) self.assertIn("/api/external/health", audit_logs[0]["details"]) - def test_external_account_status_returns_account_data_and_audits(self): + @patch("outlook_web.services.external_api.graph_service.get_emails_graph") + def test_external_account_status_returns_account_data_and_audits(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( @@ -461,11 +652,36 @@ class ExternalApiSystemTests(ExternalApiBaseTest): self.assertIn("preferred_method", data.get("data", {})) self.assertIn("last_refresh_at", data.get("data", {})) self.assertTrue(data.get("data", {}).get("can_read")) + self.assertTrue(data.get("data", {}).get("upstream_probe_ok")) + self.assertTrue(data.get("data", {}).get("last_probe_at")) + self.assertEqual(data.get("data", {}).get("probe_method"), "Graph API") + self.assertEqual(data.get("data", {}).get("last_probe_error"), "") audit_logs = self._external_audit_logs() self.assertEqual(len(audit_logs), 1) self.assertIn("/api/external/account-status", audit_logs[0]["details"]) + @patch("outlook_web.services.external_api.imap_service.get_emails_imap_with_server") + @patch("outlook_web.services.external_api.graph_service.get_emails_graph") + def test_external_account_status_probe_failure_returns_probe_summary(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": {"message": "token invalid"}} + mock_get_emails_imap.return_value = {"success": False, "error": {"message": "imap fallback failed"}} + 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().get("data", {}) + self.assertFalse(data.get("upstream_probe_ok")) + self.assertEqual(data.get("probe_method"), "graph") + self.assertTrue(data.get("last_probe_at")) + self.assertTrue(data.get("last_probe_error")) + class ExternalApiRegressionTests(ExternalApiBaseTest): @patch("outlook_web.services.graph.get_emails_graph") @@ -604,7 +820,7 @@ class ExternalApiSchemaValidationTests(ExternalApiBaseTest): self.assertEqual(resp.status_code, 200) data = resp.get_json().get("data", {}) - for key in ("status", "service", "version", "server_time_utc", "database"): + for key in ("status", "service", "version", "server_time_utc", "database", "upstream_probe_ok", "last_probe_at", "last_probe_error"): self.assertIn(key, data, f"HealthData 缺少字段: {key}") def test_capabilities_response_schema_has_required_fields(self): @@ -618,15 +834,17 @@ class ExternalApiSchemaValidationTests(ExternalApiBaseTest): self.assertIn(key, data, f"CapabilitiesData 缺少字段: {key}") self.assertIsInstance(data["features"], list) - def test_account_status_response_schema_has_required_fields(self): + @patch("outlook_web.services.external_api.graph_service.get_emails_graph") + def test_account_status_response_schema_has_required_fields(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/account-status?email={email_addr}", headers=self._auth_headers()) self.assertEqual(resp.status_code, 200) data = resp.get_json().get("data", {}) - for key in ("email", "exists"): + for key in ("email", "exists", "upstream_probe_ok", "probe_method", "last_probe_at", "last_probe_error"): self.assertIn(key, data, f"AccountStatusData 缺少字段: {key}") self.assertIn("status", data, "AccountStatusData 应返回 status 字段") @@ -1041,7 +1259,318 @@ class ExternalApiSystemErrorTests(ExternalApiBaseTest): # --------------------------------------------------------------------------- -# TC-REG-02, TC-REG-05: 旧接口回归补充 +# BUG-00017 止血:营销邮件误命中 & 低置信度拦截 +# --------------------------------------------------------------------------- +class ExternalApiVerificationConfidenceTests(ExternalApiBaseTest): + """BUG-00017: 低置信度结果不再返回 200 OK""" + + @patch("outlook_web.services.graph.get_email_raw_graph") + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_marketing_email_code_returns_404(self, mock_list, mock_detail, mock_raw): + """营销邮件中的普通数字不应被当作成功验证码返回""" + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_list.return_value = { + "success": True, + "emails": [self._graph_email( + subject="Runpod - 50% OFF GPU Instances", + sender="marketing@runpod.io", + )], + } + # 关键:detail 的 subject 也要与 email 列表一致(营销主题) + marketing_detail = { + "id": "msg-1", + "subject": "Runpod - 50% OFF GPU Instances", + "from": {"emailAddress": {"address": "marketing@runpod.io"}}, + "toRecipients": [{"emailAddress": {"address": "user@outlook.com"}}], + "receivedDateTime": self._utc_iso(), + "body": {"content": "Save big on 1181 new GPU instances! Order now for $2999/month.", "contentType": "text"}, + } + mock_detail.return_value = marketing_detail + mock_raw.return_value = "RAW" + + 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, 404, "营销邮件数字不应返回 200 成功") + self.assertEqual(resp.get_json().get("code"), "VERIFICATION_CODE_NOT_FOUND") + + @patch("outlook_web.services.graph.get_email_raw_graph") + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_marketing_email_link_returns_404(self, mock_list, mock_detail, mock_raw): + """营销邮件中的普通链接不应被当作成功验证链接返回""" + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_list.return_value = { + "success": True, + "emails": [self._graph_email( + subject="Weekly Newsletter - Check out new features", + sender="news@example.com", + )], + } + mock_detail.return_value = { + "id": "msg-1", + "subject": "Weekly Newsletter - Check out new features", + "from": {"emailAddress": {"address": "news@example.com"}}, + "toRecipients": [{"emailAddress": {"address": "user@outlook.com"}}], + "receivedDateTime": self._utc_iso(), + "body": { + "content": "Read more at https://blog.example.com/latest-news and https://shop.example.com/deals", + "contentType": "text", + }, + } + mock_raw.return_value = "RAW" + + 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, 404, "营销链接不应返回 200 成功") + self.assertEqual(resp.get_json().get("code"), "VERIFICATION_LINK_NOT_FOUND") + + @patch("outlook_web.services.graph.get_email_raw_graph") + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_legit_verification_code_still_succeeds(self, mock_list, mock_detail, mock_raw): + """标准验证码邮件仍可正常成功提取(高置信度 → 200 OK)""" + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_list.return_value = { + "success": True, + "emails": [self._graph_email(subject="Your verification code")], + } + mock_detail.return_value = self._graph_detail( + body_text="Your verification code is 987654. Do not share this code.", + ) + mock_raw.return_value = "RAW" + + 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["data"]["verification_code"], "987654") + self.assertEqual(data["data"]["code_confidence"], "high") + + @patch("outlook_web.services.graph.get_email_raw_graph") + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_legit_verification_link_still_succeeds(self, mock_list, mock_detail, mock_raw): + """标准验证链接邮件仍可正常成功提取(高置信度 → 200 OK)""" + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_list.return_value = { + "success": True, + "emails": [self._graph_email(subject="Confirm your email address")], + } + mock_detail.return_value = self._graph_detail( + body_text="Click https://auth.example.com/verify?token=abc to confirm your email.", + ) + mock_raw.return_value = "RAW" + + 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["data"]["verification_link"]) + self.assertEqual(data["data"]["link_confidence"], "high") + + @patch("outlook_web.services.graph.get_email_raw_graph") + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_low_confidence_code_response_includes_confidence_metadata(self, mock_list, mock_detail, mock_raw): + """低置信度返回 404 时,仍可从错误中辨别原因(非邮件不存在,而是无可信结果)""" + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_list.return_value = { + "success": True, + "emails": [self._graph_email(subject="System Report")], + } + # 关键:detail 的 subject 也要是非验证码主题 + report_detail = { + "id": "msg-1", + "subject": "System Report", + "from": {"emailAddress": {"address": "noreply@example.com"}}, + "toRecipients": [{"emailAddress": {"address": "user@outlook.com"}}], + "receivedDateTime": self._utc_iso(), + "body": {"content": "There are 445566 active users this quarter.", "contentType": "text"}, + } + mock_detail.return_value = report_detail + mock_raw.return_value = "RAW" + + 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, 404) + self.assertEqual(resp.get_json().get("code"), "VERIFICATION_CODE_NOT_FOUND") + + @patch("outlook_web.services.graph.get_email_raw_graph") + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_code_with_custom_regex_still_returns_high_confidence(self, mock_list, mock_detail, mock_raw): + """调用方传入 code_regex 精确匹配时,关键词命中仍返回 high confidence""" + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_list.return_value = { + "success": True, + "emails": [self._graph_email(subject="Your OTP code")], + } + mock_detail.return_value = self._graph_detail( + body_text="Your OTP code is AB1234. Enter it within 5 minutes.", + ) + mock_raw.return_value = "RAW" + + client = self.app.test_client() + resp = client.get( + f"/api/external/verification-code?email={email_addr}&code_regex=%5Cb%5BA-Z0-9%5D%7B6%7D%5Cb", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data["data"]["verification_code"], "AB1234") + self.assertEqual(data["data"]["code_confidence"], "high") + + @patch("outlook_web.services.graph.get_email_raw_graph") + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_code_regex_without_keyword_context_still_succeeds(self, mock_list, mock_detail, mock_raw): + """code_regex 精确匹配,邮件无验证码关键词 → 仍应返回 200""" + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_list.return_value = { + "success": True, + "emails": [self._graph_email(subject="Account notice")], + } + detail = { + "id": "msg-1", + "subject": "Account notice", + "from": {"emailAddress": {"address": "noreply@example.com"}}, + "toRecipients": [{"emailAddress": {"address": "user@outlook.com"}}], + "receivedDateTime": self._utc_iso(), + "body": {"content": "Use AB1234 within 5 minutes to proceed.", "contentType": "text"}, + } + mock_detail.return_value = detail + mock_raw.return_value = "RAW" + + client = self.app.test_client() + resp = client.get( + f"/api/external/verification-code?email={email_addr}&code_regex=%5Cb%5BA-Z%5D%7B2%7D%5Cd%7B4%7D%5Cb", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200, "code_regex 精确匹配不应被拦截") + data = resp.get_json() + self.assertEqual(data["data"]["verification_code"], "AB1234") + self.assertEqual(data["data"]["code_confidence"], "high") + + @patch("outlook_web.services.graph.get_email_raw_graph") + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_opaque_verify_link_with_email_context_succeeds(self, mock_list, mock_detail, mock_raw): + """URL 不含验证关键词但邮件正文有验证语境 → 应返回 200""" + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_list.return_value = { + "success": True, + "emails": [self._graph_email(subject="Please verify your account")], + } + detail = { + "id": "msg-1", + "subject": "Please verify your account", + "from": {"emailAddress": {"address": "noreply@example.com"}}, + "toRecipients": [{"emailAddress": {"address": "user@outlook.com"}}], + "receivedDateTime": self._utc_iso(), + "body": {"content": "Click to verify your email: https://auth.example.com/t/abc123", "contentType": "text"}, + } + mock_detail.return_value = detail + mock_raw.return_value = "RAW" + + 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.assertIn("auth.example.com", data["data"]["verification_link"]) + self.assertEqual(data["data"]["link_confidence"], "high") + + @patch("outlook_web.services.graph.get_email_raw_graph") + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_discount_code_email_link_returns_404(self, mock_list, mock_detail, mock_raw): + """营销邮件正文含 'discount code' + 普通链接 → 不应被提权,应返回 404""" + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_list.return_value = {"success": True, "emails": [self._graph_email()]} + detail = { + "id": "msg-1", + "subject": "Your exclusive discount code inside!", + "from_address": "deals@shop.example.com", + "content": "Use discount code SAVE20 at https://shop.example.com/checkout", + "html_content": "", + "received_at": "2026-03-08T12:00:00Z", + } + mock_detail.return_value = detail + mock_raw.return_value = "RAW" + + 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, 404, + "'discount code' 语境不应让普通链接通过门控") + + @patch("outlook_web.services.graph.get_email_raw_graph") + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_confirm_your_order_link_returns_404(self, mock_list, mock_detail, mock_raw): + """'confirm your order' 不是验证语境 → 普通链接应返回 404""" + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_list.return_value = {"success": True, "emails": [self._graph_email()]} + detail = { + "id": "msg-1", + "subject": "Please confirm your order", + "from_address": "orders@shop.example.com", + "content": "Click here to confirm your order: https://shop.example.com/orders/status/789", + "html_content": "", + "received_at": "2026-03-08T12:00:00Z", + } + mock_detail.return_value = detail + mock_raw.return_value = "RAW" + + 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, 404, + "'confirm your order' 不应让普通链接通过门控") # --------------------------------------------------------------------------- class ExternalApiRegressionExtendedTests(ExternalApiBaseTest): """TC-REG-02, TC-REG-05""" diff --git a/tests/test_verification_extractor_options.py b/tests/test_verification_extractor_options.py index b1c8fbd..57954a7 100644 --- a/tests/test_verification_extractor_options.py +++ b/tests/test_verification_extractor_options.py @@ -218,5 +218,264 @@ class VerificationExtractorEdgeCaseTests(unittest.TestCase): self.assertEqual(result.get("confidence"), "low") +class VerificationExtractorConfidenceTests(unittest.TestCase): + """BUG-00017 止血:code_confidence / link_confidence 细粒度置信度测试""" + + 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 + + # ── code_confidence ── + + def test_code_confidence_high_when_keyword_match(self): + """关键词命中验证码 → code_confidence=high""" + func = self._require_new_api() + email = { + "subject": "Your verification code", + "body": "Your verification code is 654321", + "body_html": "", + } + result = func(email) + self.assertEqual(result["verification_code"], "654321") + self.assertEqual(result["code_confidence"], "high") + + def test_code_confidence_low_when_fallback_only(self): + """仅 fallback 命中数字 → code_confidence=low""" + func = self._require_new_api() + email = { + "subject": "Weekly newsletter", + "body": "We have 1181 new features this month", + "body_html": "", + } + result = func(email) + self.assertIsNotNone(result.get("verification_code"), "应命中 1181 作为 fallback 候选") + self.assertEqual(result["code_confidence"], "low") + + def test_code_confidence_low_for_marketing_numbers(self): + """营销邮件中的普通数字不应获得 high confidence""" + func = self._require_new_api() + email = { + "subject": "50% OFF - Huge Sale!", + "body": "Save big! Use discount 2999 for GPU instances. Order #8847 confirmed.", + "body_html": "", + } + result = func(email) + self.assertIsNotNone(result.get("verification_code"), "应命中某个数字作为 fallback 候选") + self.assertEqual(result["code_confidence"], "low", + "营销邮件数字不应获得 high confidence") + + # ── link_confidence ── + + def test_link_confidence_high_when_verify_keyword_in_url(self): + """链接含 verify 关键词 → link_confidence=high""" + func = self._require_new_api() + email = { + "subject": "Verify your email", + "body": "Click https://example.com/verify?token=abc123 to activate", + "body_html": "", + } + result = func(email) + self.assertEqual(result["link_confidence"], "high") + self.assertIn("verify", result["verification_link"]) + + def test_link_confidence_high_when_confirm_keyword_in_url(self): + """链接含 confirm 关键词 → link_confidence=high""" + func = self._require_new_api() + email = { + "subject": "Confirm your account", + "body": "Please visit https://example.com/confirm?id=xyz", + "body_html": "", + } + result = func(email) + self.assertEqual(result["link_confidence"], "high") + + def test_link_confidence_low_for_generic_link(self): + """链接不含验证关键词 → link_confidence=low""" + func = self._require_new_api() + email = { + "subject": "Check out our sale", + "body": "Visit https://shop.example.com/deals for amazing offers", + "body_html": "", + } + result = func(email) + self.assertIsNotNone(result.get("verification_link")) + self.assertEqual(result["link_confidence"], "low", + "普通链接不应获得 high link_confidence") + + def test_link_confidence_low_for_marketing_link(self): + """营销邮件中的普通跳转链接 → link_confidence=low""" + func = self._require_new_api() + email = { + "subject": "Runpod - New GPU Instances Available", + "body": "Check out https://www.runpod.io/pricing and https://blog.runpod.io/update", + "body_html": "", + } + result = func(email) + self.assertIsNotNone(result.get("verification_link"), "应命中某个链接作为 fallback") + self.assertEqual(result["link_confidence"], "low", + "营销链接不应获得 high link_confidence") + + # ── confidence 总字段向后兼容 ── + + def test_confidence_compat_high_when_code_high(self): + """code_confidence=high → 总 confidence=high(向后兼容)""" + func = self._require_new_api() + email = { + "subject": "OTP", + "body": "Your OTP is 998877", + "body_html": "", + } + result = func(email) + self.assertEqual(result["code_confidence"], "high") + self.assertEqual(result["confidence"], "high") + + def test_confidence_compat_high_when_link_high(self): + """link_confidence=high → 总 confidence=high(向后兼容)""" + func = self._require_new_api() + email = { + "subject": "Hello", + "body": "Random text with https://example.com/verify?t=1", + "body_html": "", + } + result = func(email) + self.assertEqual(result["link_confidence"], "high") + self.assertEqual(result["confidence"], "high") + + def test_confidence_compat_low_when_both_low(self): + """code 和 link 均 low → 总 confidence=low""" + func = self._require_new_api() + email = { + "subject": "Sale alert", + "body": "Item 5678 at https://shop.example.com/deals", + "body_html": "", + } + result = func(email) + self.assertEqual(result["code_confidence"], "low") + self.assertEqual(result["link_confidence"], "low") + self.assertEqual(result["confidence"], "low") + + # ── 审查修复:补充关键场景测试 ── + + def test_code_regex_without_keyword_gets_high_confidence(self): + """调用方传入 code_regex 精确匹配,即使无关键词上下文也应 high confidence""" + func = self._require_new_api() + email = { + "subject": "Account notice", + "body": "Use AB1234 within 5 minutes to proceed.", + "body_html": "", + } + result = func(email, code_regex=r"\b[A-Z]{2}\d{4}\b") + self.assertEqual(result["verification_code"], "AB1234") + self.assertEqual(result["code_confidence"], "high", + "code_regex 精确匹配命中应视为 high confidence") + + def test_code_length_without_keyword_stays_low_confidence(self): + """code_length 仅限定宽度,不具备判别力 → 无关键词上下文时 code_confidence 仍为 low""" + func = self._require_new_api() + email = { + "subject": "Action required", + "body": "Enter 887766 to continue your setup process.", + "body_html": "", + } + result = func(email, code_length="6-6") + self.assertEqual(result["verification_code"], "887766") + self.assertEqual(result["code_confidence"], "low", + "code_length 不应自动提权为 high,避免营销邮件订单号绕过门控") + + def test_link_high_confidence_from_email_context(self): + """URL 不含验证关键词但邮件正文有强验证语境短语 → link_confidence=high""" + func = self._require_new_api() + email = { + "subject": "Please verify your account", + "body": "Click the link below to verify your email: https://auth.example.com/t/abc123", + "body_html": "", + } + result = func(email) + self.assertIsNotNone(result.get("verification_link")) + self.assertEqual(result["link_confidence"], "high", + "邮件正文含强验证语境短语时,即使 URL 不含关键词也应 high") + + # ── G3 反例:宽词语境 + 普通链接不应被提权 ── + + def test_discount_code_email_link_stays_low(self): + """正文含 'discount code' 但这不是验证语境 → link_confidence 仍为 low""" + func = self._require_new_api() + email = { + "subject": "Your exclusive discount code inside!", + "body": "Use discount code SAVE20 at https://shop.example.com/checkout", + "body_html": "", + } + result = func(email) + self.assertIsNotNone(result.get("verification_link")) + self.assertEqual(result["link_confidence"], "low", + "'discount code' 不应触发链接验证语境提权") + + def test_promo_code_email_link_stays_low(self): + """正文含 'promo code' → link_confidence 仍为 low""" + func = self._require_new_api() + email = { + "subject": "Special promo code for you", + "body": "Apply promo code SPRING25 here: https://deals.example.com/apply", + "body_html": "", + } + result = func(email) + self.assertIsNotNone(result.get("verification_link")) + self.assertEqual(result["link_confidence"], "low", + "'promo code' 不应触发链接验证语境提权") + + def test_order_confirmation_link_stays_low(self): + """正文含 'order confirmation' 但只是交易确认 → link_confidence 仍为 low""" + func = self._require_new_api() + email = { + "subject": "Order confirmation #12345", + "body": "View your order confirmation at https://store.example.com/orders/12345", + "body_html": "", + } + result = func(email) + self.assertIsNotNone(result.get("verification_link")) + self.assertEqual(result["link_confidence"], "low", + "'order confirmation' 不应触发链接验证语境提权") + + def test_confirm_your_order_link_stays_low(self): + """正文含 'confirm your order'(非邮箱/账户对象)→ low""" + func = self._require_new_api() + email = { + "subject": "Please confirm your order", + "body": "Click here to confirm your order: https://shop.example.com/orders/status/789", + "body_html": "", + } + result = func(email) + self.assertIsNotNone(result.get("verification_link")) + self.assertEqual(result["link_confidence"], "low", + "'confirm your order' 不应触发链接验证语境提权") + + def test_activate_your_subscription_link_stays_low(self): + """正文含 'activate your subscription'(非邮箱/账户对象)→ low""" + func = self._require_new_api() + email = { + "subject": "Activate your subscription today!", + "body": "Ready to activate your subscription? Visit https://service.example.com/subscribe/start", + "body_html": "", + } + result = func(email) + self.assertIsNotNone(result.get("verification_link")) + self.assertEqual(result["link_confidence"], "low", + "'activate your subscription' 不应触发链接验证语境提权") + + def test_verify_your_purchase_link_stays_low(self): + """正文含 'verify your purchase'(非邮箱/账户对象)→ low""" + func = self._require_new_api() + email = { + "subject": "Verify your purchase details", + "body": "Please verify your purchase at https://store.example.com/orders/review/456", + "body_html": "", + } + result = func(email) + self.assertIsNotNone(result.get("verification_link")) + self.assertEqual(result["link_confidence"], "low", + "'verify your purchase' 不应触发链接验证语境提权") + + if __name__ == "__main__": unittest.main()