feat: add external api docs tests and verification updates

This commit is contained in:
胡绍康 committed 2026-03-09 15:07:18 +08:00
1 parent 8ee73e6572
commit 9673b3524d
24 files changed
+6147 -20

No files matched your search

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