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>
This commit is contained in:
胡绍康andCopilot committed 2026-03-12 23:24:02 +08:00
1 parent 7163b3bc17
commit 09c3ab3416
5 files changed
+1179 -52

No files matched your search

+87 -43
View File
@@ -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/<id>` |
| 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/*` 主业务路径下。
+239
View File
@@ -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
+60 -4
View File
@@ -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,
}
+534 -5
View File
@@ -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"""
@@ -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()