feat: add external api docs tests and verification updates
This commit is contained in:
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
@@ -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` 保证测试稳定、快速。
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(安全实现)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user