feat: PRD-00008 P1 公网模式安全层 — 守卫模块、设置 UI、321 测试全绿

- 新增 external_api_guard.py:IP 白名单、限流(SQLite 分钟桶)、高风险端点禁用
- DB v5→v6:新增 external_api_rate_limits 表 + 5 项 P1 默认设置
- 10 个外部 API 端点集成 @external_api_guards() 装饰器
- capabilities 端点增强:public_mode / restricted_features
- settings 控制器 + 前端 UI:公网模式开关、IP 白名单、限流、功能禁用
- 新增 18 个 P1 守卫测试(TC-GUARD-01~18),总计 321 测试全绿
- 更新 api.md:P1 错误码、配置文档
- 默认 public_mode=false,与 P0 行为完全兼容

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
胡绍康andCopilot committed 2026-03-11 22:18:40 +08:00
1 parent c79a173c03
commit a01b48a813
17 files changed
+2593 -198

No files matched your search

+34
View File
@@ -202,6 +202,40 @@ user@outlook.com----password123----24d9a0ed-xxxx----0.AXEA...
- ✅ 导出功能二次验证
- ✅ 审计日志记录
## 📡 对外 API 说明
项目当前已提供 `/api/external/*` 对外只读接口,覆盖:
- 邮件列表 / 最新邮件 / 邮件详情 / RAW 内容
- 验证码提取 / 验证链接提取
- 健康检查 / 能力说明 / 账号状态检查
当前版本的正式定位是:
- 适用于**本地化部署**
- 适用于**单实例、单可信调用方**
- 适用于**内网或受控访问环境**
当前版本**不建议直接公网暴露**。主要原因:
- `wait-message` 是同步长轮询接口,会占用请求线程
- Docker 默认使用单 Gunicorn worker,长请求会影响整体吞吐
- 当前只有 `X-API-Key` 鉴权,尚未内建公网模式、IP 白名单、限流与多调用方隔离
- 当前单 `external_api_key` 默认具备读取本实例全部已配置邮箱的能力边界
如果确需对外接入,建议至少满足以下条件:
- 只在 HTTPS 反向代理之后暴露
- 优先放在内网、VPN 或来源 IP 白名单之后
- 不把 API Key 放进查询参数
- 公网场景下避免开放 `/api/external/wait-message` 与 RAW 内容接口
详细分析见:
- `docs/BUG/BUG-00013-对外开放API公网暴露风险与可用性缺口.md`
- `docs/api.md`
- `docs/FD/OPENAPI-00008-对外验证码与邮件读取开放API.yaml`
## 🛠️ 技术栈
**后端:**
@@ -0,0 +1,439 @@
# BUG-00013 对外开放 API 公网暴露风险与可用性缺口
> 关联需求: `PRD-00008` / `FD-00008` / `TDD-00008`
> 分析时间: 2026-03-11
> 分析范围: `outlook_web/services/external_api.py`、`outlook_web/controllers/emails.py`、`outlook_web/controllers/system.py`、`outlook_web/security/auth.py`、`Dockerfile`
---
## 一、问题概述
| 编号 | 标题 | 严重程度 | 类型 |
|------|------|---------|------|
| **E-1** | 对外开放 API 已具备功能,但尚不适合直接公网暴露 | 🔴 高 | 架构 / 可用性 / 安全 |
---
## 二、结论摘要
当前项目的 `/api/external/*` 已经完成了接口实现、`X-API-Key` 鉴权、设置页接入、OpenAPI 草稿和专项测试,**可以用于单一可信调用方、受控私有部署下的试运行**。
但如果目标是“作为真实对外 API 长期稳定暴露给外部系统接入”,当前实现仍存在明显缺口:
1. **可用性层面**:同步长轮询接口与单 worker 部署模型叠加,容易阻塞整个服务。
2. **安全层面**:只有 API Key,没有限流、白名单、配额或调用方隔离。
3. **运维层面**:健康检查与账号状态检查偏静态,不能证明真实上游可读。
4. **产品层面**:当前是“单实例单 Key 读全部邮箱”模型,只适合自用,不适合作为多方接入平台。
因此,本问题不是“接口是否存在”,而是**接口是否已经达到可对外暴露的工程标准**。当前答案是:**功能上可用,工程上未达公网开放标准**。
---
## 三、现状与 PRD 对照
### 3.1 已完成项
当前实现已经满足以下目标:
| 能力 | 状态 | 说明 |
|------|------|------|
| `/api/external/*` 路由 | ✅ 已实现 | 已覆盖 messages / latest / detail / raw / verification / wait / health / capabilities / account-status |
| `X-API-Key` 鉴权 | ✅ 已实现 | 与后台登录态隔离 |
| 设置项 `external_api_key` | ✅ 已接入 | 已支持保存、加密存储、脱敏回显 |
| 审计日志 | ✅ 已实现 | 开放接口调用已写入 `audit_logs` |
| 主路径专项测试 | ✅ 已通过 | `python -m unittest tests.test_external_api -v` 共 21 项通过 |
### 3.2 未满足项
PRD-00008 的真实目标并不只是“把接口做出来”,而是满足:
- 本地化部署后的自动化注册流程稳定接入
- 自动提取失败后的人工排查
- 本地部署完成后的连通性与可用性确认
- 对外接口认证与错误码规范化
当前与这个目标的偏差主要体现在:
| 目标 | 当前情况 | 风险 |
|------|----------|------|
| 对外长期稳定提供服务 | 单 worker + 同步轮询 | 容易阻塞、吞吐低 |
| 可控对外暴露 | 仅 API Key | Key 泄露后攻击面过大 |
| 自检能证明真实可用 | 多为静态检查 | 可能“自检成功但实际不可读” |
| 面向外部系统接入 | 单 Key 读全实例邮箱 | 权限边界过粗 |
---
## 四、根因分析
### 4.1 单 worker 与 `wait-message` 同步轮询叠加
当前 Docker 默认使用 Gunicorn 单 worker:
```dockerfile
CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:5000", "--timeout", "120", "--access-logfile", "-", "web_outlook_app:app"]
```
同时,`wait-message` 首版采用同步轮询实现,最大可阻塞 120 秒:
```python
while True:
latest_message = get_latest_message_for_external(...)
if int(latest_message.get("timestamp") or 0) >= baseline_timestamp:
return latest_message
if time.time() - start >= timeout_seconds:
raise MailNotFoundError(...)
time.sleep(poll_interval)
```
这会导致:
1. 一个长轮询请求就可能占满唯一 worker。
2. Graph / IMAP 上游变慢时,普通查询接口也会排队。
3. 后台页面与外部 API 共用同一个进程池,互相影响。
这类实现可用于“内网低频自用”,但不适合真实公网暴露。
### 4.2 鉴权只有 API Key,没有第二道防线
当前开放接口仅做:
- Header 中读取 `X-API-Key`
- 与全局 `external_api_key` 比较
缺少:
- IP 白名单
- 外部接口限流
- 单调用方配额
- 多 API Key 隔离
- 按邮箱范围授权
- 公网模式下的接口分级开关
因此当前模型本质上是:
> 一个 Key = 全实例全部已配置邮箱的只读能力
这适合“自己调用自己的本地服务”,不适合“提供给多个外部系统接入”。
### 4.3 自检接口偏静态,不证明真实上游可读
`/api/external/health` 目前主要检查:
- 服务是否存活
- SQLite 是否可访问
`/api/external/account-status` 主要检查:
- 账号是否存在
- 状态是否为 disabled
- 凭证字段是否为空
但它没有真正做:
- Graph 连通性探测
- IMAP 登录探测
- 最近一封邮件读取探测
因此会出现:
| 场景 | 当前结果 | 实际情况 |
|------|----------|----------|
| refresh token 已失效 | `account-status` 可能显示 `can_read=true` | 实际读取失败 |
| IMAP 密码字段存在但错误 | `account-status` 可能显示可读 | 实际读取失败 |
| 上游 Graph 网络故障 | `health` 仍返回服务正常 | 实际外部调用不可用 |
### 4.4 文档与发布定位还停留在草案/内部交付阶段
OpenAPI 文档当前仍为 `0.1.0-draft`,并明确标注为草稿。
README 当前也没有完整覆盖:
- 外部 API 接入方式
- 反向代理/TLS 示例
- 生产模式安全建议
- `wait-message` 的并发影响
- 建议禁用/限制的公网接口
这说明当前能力更像“项目内可交付功能”,还不是“可对外发布产品”。
---
## 五、与 `exeample/` 中其他项目对比
本次对比重点看三个示例:
1. `exeample/outlookEmail`
2. `exeample/BillionMail`
3. `exeample/MailAggregator_Pro`
---
## 六、可借鉴方案分析
### 6.1 `exeample/outlookEmail`:可借鉴“最小外部 API 闭环”,但不能照搬安全方案
该示例已经实现了一个最小可用外部接口:
- `GET /api/external/emails`
- 设置页支持 `external_api_key`
- 有单独的 `docs/api.md`
优点:
| 项目 | 可借鉴点 |
|------|----------|
| 接口范围控制 | 首版只开放一个外部查询接口,范围很收敛 |
| 文档闭环 | 有明确 curl 示例和错误响应说明 |
| 设置页接入 | 用户能直观看到如何配置和使用 |
不足:
| 项目 | 不可照搬点 |
|------|------------|
| API Key 传递 | 同时支持 Header 和 query 参数 `api_key`,会增加泄露风险 |
| 存储方式 | `external_api_key` 为明文读取 |
| 能力边界 | 只有最小接口,未处理公网暴露时的工程问题 |
结论:
- **可借鉴**:外部 API 需要独立文档、最小首发能力、清晰调用示例。
- **不可照搬**:query 传 key、明文存储、过于简化的安全模型。
### 6.2 `exeample/BillionMail`:可借鉴“外围防护优先”思路
`BillionMail` 虽然不是同类读取 API 项目,但它在对外暴露上有两个非常实用的外围防护思路:
1. `FAIL2BAN_INIT=y`
2. `IP_WHITELIST_ENABLE=false/true`
这说明它把“能否被访问”放在应用逻辑之外的一层去控制:
| 机制 | 含义 | 对我们可借鉴之处 |
|------|------|------------------|
| Fail2Ban | 对异常访问做封禁 | 外部接口应有暴力访问抑制 |
| IP 白名单开关 | 限制管理入口来源 IP | 对外 API 应支持白名单模式 |
结论:
- 我们当前项目缺少的,不只是代码层鉴权,还缺**外围防护开关**。
- 对于自部署场景,**反向代理白名单 + 应用层 API Key** 的双层模型明显更稳。
### 6.3 `exeample/MailAggregator_Pro`:可借鉴“后台轮询与 Web 接口解耦”
`MailAggregator_Pro` 的核心特点不是开放 API,而是把轮询任务放到后台 worker/poller 中:
- `main.py` 在启动时创建 `poller_loop()`
- `worker/poller.py` 持续异步轮询
- `api/health.py` 暴露轮询状态
这类设计对我们有两个启发:
| 方向 | 可借鉴点 |
|------|----------|
| 长耗时任务 | 不应该依赖请求线程同步等待 |
| 健康检查 | 应暴露“后台轮询是否正常工作”的状态,而不只是进程存活 |
结论:
- `wait-message` 如果要保留长期能力,应逐步改成“后台轮询 / 事件缓存 / 异步等待”模式。
- 对外健康检查应补充“最近轮询时间、最近一次错误、上游探测结果”等动态指标。
---
## 七、建议解决方案
### 7.1 总体策略
不建议把当前版本直接定义为“公网开放 API”,建议分三阶段推进:
| 阶段 | 定位 | 目标 |
|------|------|------|
| P0 | 受控私有接入 | 仅供单一可信调用方使用 |
| P1 | 半开放部署 | 具备外围防护与接口分级 |
| P2 | 正式外部能力 | 具备多调用方与更强可用性能力 |
在具体实现上,建议始终保留两条主线:
1. **鉴权收敛**:继续坚持 `X-API-Key` 单入口,后续再逐步补公网模式、来源白名单、限流、多 Key 与范围授权。
2. **轮询解耦**:`wait-message` 不应长期依赖请求线程同步等待,后续应逐步迁移到后台轮询、事件缓存、异步等待或独立 worker 模式。
---
## 八、分阶段实施方案
### 8.1 P0:先收敛发布口径,明确“仅适合受控私有接入”
建议立即补充文档与部署口径:
1. README 明确声明:
- 当前外部 API 适用于本地化部署、单实例、单可信调用方。
- 不建议直接暴露在公网。
2. OpenAPI/FD/README 中标记:
- `wait-message`
- `/raw`
为高风险/高成本接口。
3. 补充反向代理建议:
- 必须使用 HTTPS
- 建议仅内网访问或白名单访问
这是最快能落地、风险最低的动作。
### 8.2 P1:补外围防护与接口分级
建议新增以下能力:
| 能力 | 说明 | 参考来源 |
|------|------|----------|
| `EXTERNAL_API_PUBLIC_MODE` | 公网模式开关 | 当前项目新增 |
| `EXTERNAL_API_IP_WHITELIST` | 支持来源 IP 白名单 | 借鉴 `BillionMail` |
| 外部接口限流 | 按 IP / API Key 限制请求频率 | 借鉴 `BillionMail` 的 fail2ban 思路 |
| 接口分级控制 | 公网模式禁用 `/wait-message`、`/raw` | 当前项目新增 |
建议规则:
```text
私有模式:
允许全部 external API
公网模式:
默认禁用 /api/external/wait-message
默认禁用 /api/external/messages/{id}/raw
必须启用白名单或限流
```
### 8.3 P1:增强自检接口,让“可读性”变成真实探测
建议新增或改造:
| 接口 | 当前 | 建议 |
|------|------|------|
| `/api/external/health` | 服务/DB 存活 | 增加上游探测摘要 |
| `/api/external/account-status` | 静态字段判断 | 增加真实读取探测结果 |
| `/api/external/capabilities` | 能力列表 | 增加 `public_mode`、`restricted_features` |
建议返回增加:
- `upstream_probe_ok`
- `probe_method`
- `last_probe_at`
- `last_probe_error`
- `public_mode`
- `restricted_features`
### 8.4 P2:重构 `wait-message`,避免阻塞请求线程
建议不要长期保持“请求线程里 while + sleep”的模式,后续应改为以下之一:
1. 后台轮询缓存
2. 事件队列 / 消息中转
3. 独立 worker + 轮询结果表
这一点可以参考 `MailAggregator_Pro` 的思路:
- Web API 负责查询状态
- Worker 负责持续轮询与更新结果
这一方向应视为后续“**轮询解耦**”主线,而不是可有可无的优化项。
如果暂时不做完整异步化,最低也应:
- 在公网模式禁用 `wait-message`
- 私有模式下明确其高成本属性
### 8.5 P2:补权限模型,避免“一把 Key 读全实例”
建议后续设计:
| 能力 | 作用 |
|------|------|
| 多 API Key | 区分不同调用方 |
| Key 级邮箱白名单 | 限制某个 Key 只能读部分邮箱 |
| Key 状态管理 | 支持启停、轮换、吊销 |
| 调用审计增强 | 记录调用方标识、命中邮箱范围 |
当前单 `external_api_key` 模式适合首版,但不适合作为长期外部平台方案。
这一方向应视为后续“**鉴权收敛**”主线。
---
## 九、建议落地顺序
### M1:文档与发布口径修正
- 新增 README 外部 API 章节
- 标记“私有接入可用,公网开放不推荐”
- 标记高风险接口
### M2:外围防护
- 增加公网模式开关
- 增加 IP 白名单
- 增加外部接口限流
### M3:自检增强
- 增加上游探测
- 动态暴露最近一次探测结果
### M4:异步化或降级 `wait-message`
- 优先禁公网
- 后续改为后台轮询模式
### M5:多调用方能力
- 多 Key
- 授权范围
- 配额与吊销
---
## 十、最终判断
### 10.1 当前是否“可用”
**可用。**
适用于:
- 本地部署
- 单一可信调用方
- 内网或受控访问环境
- 自动化脚本低频调用
### 10.2 当前是否“可对外暴露”
**不建议直接公网暴露。**
原因不是功能缺失,而是工程能力还未闭环:
1. 长轮询会占用单 worker。
2. 缺少限流、白名单、配额等防护层。
3. 自检接口不能证明真实可读。
4. 单 Key 读全实例的权限边界过粗。
### 10.3 推荐定位
建议当前版本的正式定位为:
> “面向本地化部署与单可信调用方的受控开放 API”
而不是:
> “可直接公网暴露的通用开放平台 API”
---
## 十一、后续文档联动建议
建议后续联动更新以下文档:
| 文档 | 建议改动 |
|------|----------|
| `README.md` | 增加外部 API 使用与安全限制说明 |
| `docs/FD/FD-00008-对外验证码与邮件读取开放API.md` | 增加“公网模式限制”设计 |
| `docs/TDD/TDD-00008-对外验证码与邮件读取开放API.md` | 增加白名单/限流/模式开关技术设计 |
| `docs/TEST/TEST-00008-对外验证码与邮件读取开放API-测试文档.md` | 增加公网模式、限流、白名单测试 |
@@ -0,0 +1,608 @@
# AD-00008|对外开放 API 安全架构设计文档
## 文档信息
- **文档编号**: AD-00008
- **创建日期**: 2026-03-11
- **版本**: v1.0
- **状态**: 草案
- **关联文档**:
- `docs/PRD/PRD-00008-对外验证码与邮件读取开放API.md`
- `docs/FD/FD-00008-对外验证码与邮件读取开放API.md`
- `docs/TDD/TDD-00008-对外验证码与邮件读取开放API.md`
- `docs/api.md`
- `docs/BUG/BUG-00013-对外开放API公网暴露风险与可用性缺口.md`
---
## 一、架构概述
### 1.1 目标
本架构文档用于说明“**对外开放 API 安全接入模块**”在当前项目中的定位、模块拆分、分层边界和后续演进方向。
这个模块的目标不是单纯多加几条 `/api/external/*` 路由,而是建立一套:
1. 能复用现有邮件读取能力的对外只读接口层
2. 能与后台登录态彻底隔离的 API Key 接入层
3. 能持续演进到公网安全治理和多调用方模型的架构基础
### 1.2 当前架构定位
当前架构定位明确为:
- **P0:受控私有接入**
- 面向**单实例**
- 面向**单可信调用方**
- 面向**本地部署 / 内网 / 受控访问环境**
当前不把该模块定义为“可直接公网暴露的开放平台模块”。
### 1.3 架构原则
1. **复用优先**:优先复用现有 Graph / IMAP / 提取器 / 设置 / 审计能力
2. **边界清晰**:外部 API 与内部登录态 API 路径、鉴权、响应口径隔离
3. **最小暴露**:首版只开放只读接口,不开放写操作
4. **安全收敛**:首版只接受 `X-API-Key`,不接受 query 参数传 Key
5. **渐进演进**:P0 先闭环,P1 再补公网防护,P2 再做平台化升级
---
## 二、新模块定义
### 2.1 模块名称
建议把本次新增能力统一视为一个新模块:
**External Open API Security Module**
中文可定义为:
**对外开放 API 安全接入模块**
### 2.2 模块职责
该模块负责:
1. 为外部调用方提供统一的 `/api/external/*` 访问入口
2. 通过 `X-API-Key` 建立独立于后台登录态的接入边界
3. 编排现有邮件读取链路,输出统一对外结构
4. 提供验证码、验证链接、详情、RAW、自检等能力
5. 记录开放接口调用审计
6. 为后续公网治理、权限模型、多调用方能力预留演进点
### 2.3 模块不负责
该模块当前不负责:
1. 多租户调用方管理
2. 公网模式完整治理
3. 应用层限流和配额平台
4. 异步消息中心或后台轮询平台
5. 邮件写操作、附件下载、Webhook 推送
---
## 三、总体架构分层
### 3.1 分层视图
建议将该模块拆成七层:
```text
外部调用方
↓
API 路由入口层
↓
外部鉴权与安全控制层
↓
Controller 协议适配层
↓
External API 编排服务层
↓
读取适配与提取能力层
↓
配置 / 仓储 / 审计 / 数据持久层
```
### 3.2 各层作用
| 层级 | 当前对应文件 | 作用 |
|------|--------------|------|
| API 路由入口层 | `outlook_web/routes/emails.py` `outlook_web/routes/system.py` | 对外暴露 `/api/external/*` 路径 |
| 外部鉴权与安全控制层 | `outlook_web/security/auth.py` | 校验 `X-API-Key`,后续承接公网模式控制 |
| Controller 协议适配层 | `outlook_web/controllers/emails.py` `outlook_web/controllers/system.py` | 参数解析、错误映射、审计触发、HTTP 返回 |
| External API 编排服务层 | `outlook_web/services/external_api.py` | 统一业务编排、筛选、等待、错误码抽象 |
| 读取适配与提取能力层 | `outlook_web/services/graph.py` `outlook_web/services/imap.py` `outlook_web/services/imap_generic.py` `outlook_web/services/verification_extractor.py` | 实际读信和验证码/链接提取 |
| 配置与仓储层 | `outlook_web/repositories/settings.py` `outlook_web/repositories/accounts.py` `outlook_web/repositories/groups.py` | 读取 API Key、邮箱账号、分组代理配置 |
| 审计与持久层 | `outlook_web/audit.py` `outlook_web/db.py` | 审计日志与设置持久化 |
---
## 四、模块拆分结果
### 4.1 这个新模块需要哪几个部分
从架构上,这个新模块至少需要以下 8 个部分:
| 编号 | 部分 | 是否已具备 | 说明 |
|------|------|------------|------|
| M1 | 外部路由入口 | 已具备 | `/api/external/*` 路由 |
| M2 | API Key 鉴权层 | 已具备 | `api_key_required()` |
| M3 | 外部协议适配层 | 已具备 | 外部 controller 与统一响应 |
| M4 | 外部编排服务层 | 已具备 | `services/external_api.py` |
| M5 | 读取适配层 | 已具备 | Graph / IMAP / Generic IMAP |
| M6 | 提取能力层 | 已具备 | `verification_extractor` |
| M7 | 配置与运营层 | 已具备 | `external_api_key` 设置、设置页入口 |
| M8 | 审计与可观测层 | 已具备 | `audit_logs(resource_type='external_api')` |
但如果要继续演进,还需要补以下 4 个部分:
| 编号 | 部分 | 当前状态 | 目标阶段 |
|------|------|----------|----------|
| M9 | 公网安全控制层 | 未独立实现 | P1 |
| M10 | 动态探测与健康增强层 | 未独立实现 | P1 |
| M11 | 异步等待 / 后台轮询层 | 未独立实现 | P2 |
| M12 | 多调用方权限模型层 | 未独立实现 | P2 |
### 4.2 当前建议的模块边界
为了避免后续继续把逻辑堆进 controller,建议以如下边界理解该模块:
#### M1 路由入口层
职责:
- 注册外部路径
- 保持与内部接口路径隔离
- 不承载业务逻辑
对应文件:
- `outlook_web/routes/emails.py`
- `outlook_web/routes/system.py`
#### M2 鉴权与安全控制层
职责:
- 校验 `X-API-Key`
- 阻断未配置 / 无效请求
- 后续承接公网模式、白名单、限流、接口分级
当前文件:
- `outlook_web/security/auth.py`
后续建议:
- 保留 `api_key_required()`
- P1 新增 `enforce_external_api_public_controls()` 一类扩展入口
#### M3 Controller 协议适配层
职责:
- 解析 query / path 参数
- 调用 service
- 把领域异常映射成统一 HTTP 响应
- 记录审计日志
当前文件:
- `outlook_web/controllers/emails.py`
- `outlook_web/controllers/system.py`
边界要求:
- 不直接写 Graph / IMAP 读取细节
- 不在 controller 中重复写提取算法
#### M4 External API 编排服务层
职责:
- 统一封装对外业务能力
- 聚合账号读取、过滤、详情、等待逻辑
- 统一错误模型与返回结构
当前文件:
- `outlook_web/services/external_api.py`
这是当前新模块中最核心的编排层。
#### M5 读取适配层
职责:
- 对接 Graph API
- 对接 Outlook IMAP
- 对接 Generic IMAP
- 保持 Graph → IMAP(New) → IMAP(Old) 回退顺序
当前文件:
- `outlook_web/services/graph.py`
- `outlook_web/services/imap.py`
- `outlook_web/services/imap_generic.py`
#### M6 提取能力层
职责:
- 统一从邮件正文中提取验证码、验证链接
- 支持自定义正则、长度范围、提取来源
当前文件:
- `outlook_web/services/verification_extractor.py`
#### M7 配置与运营层
职责:
- 持久化 `external_api_key`
- 向设置页提供脱敏展示
- 提供管理员可维护入口
当前文件:
- `outlook_web/controllers/settings.py`
- `outlook_web/repositories/settings.py`
- `templates/index.html`
- `static/js/main.js`
- `outlook_web/db.py`
#### M8 审计与可观测层
职责:
- 记录外部接口调用审计
- 输出 endpoint、email、method、error_code 等核心字段
- 为后续调用方追踪做基础
当前文件:
- `outlook_web/audit.py`
- `outlook_web/services/external_api.py`
---
## 五、核心调用链路
### 5.1 邮件列表调用链
```text
Client
↓ GET /api/external/messages
routes/emails.py
↓
api_key_required()
↓
controllers/emails.py::api_external_get_messages()
↓
services/external_api.py::list_messages_for_external()
↓
accounts_repo / groups_repo
↓
graph.py / imap.py / imap_generic.py
↓
external_api.py::filter_messages()
↓
audit_logs
↓
统一 JSON 响应
```
### 5.2 验证码提取调用链
```text
Client
↓ GET /api/external/verification-code
Controller 参数校验
↓
external_api.py::get_verification_result()
↓
get_latest_message_for_external()
↓
get_message_detail_for_external()
↓
verification_extractor.py::extract_verification_info_with_options()
↓
审计日志
↓
统一 JSON 响应
```
### 5.3 健康检查调用链
```text
Client
↓ GET /api/external/health
api_key_required()
↓
controllers/system.py::api_external_health()
↓
DB 存活检查
↓
审计日志
↓
统一 JSON 响应
```
---
## 六、数据流与依赖关系
### 6.1 配置数据流
```text
管理员设置页
↓
/api/settings
↓
controllers/settings.py
↓
repositories/settings.py
↓
settings 表
```
关键配置:
- `external_api_key`
后续 P1/P2 建议配置:
- `external_api_public_mode`
- `external_api_ip_whitelist`
- `external_api_disable_wait_message`
- `external_api_disable_raw_content`
- `external_api_rate_limit_per_minute`
### 6.2 业务数据流
```text
外部请求
↓
邮箱账号查询
↓
读取策略选择
↓
Graph / IMAP / Generic IMAP 拉取
↓
标准化数据映射
↓
筛选 / 提取 / 聚合
↓
对外响应
```
### 6.3 审计数据流
```text
开放接口调用
↓
controller/service 写审计
↓
audit_logs(resource_type = "external_api")
```
---
## 七、安全边界设计
### 7.1 当前安全边界
P0 阶段当前架构已经建立的安全边界:
1. 外部接口与内部登录态接口路径隔离
2. 外部接口统一只接受 `X-API-Key`
3. `external_api_key` 不在设置页明文回显
4. 外部接口只开放只读能力
5. 调用行为进入审计日志
### 7.2 当前架构仍未补齐的安全部分
以下部分不属于 P0 闭环,但在架构上必须标识为“预留位”:
| 能力 | 当前状态 | 风险 |
|------|----------|------|
| 公网模式开关 | 未实现 | 无法区分私网与公网策略 |
| IP 白名单 | 未实现 | 来源控制不足 |
| 应用层限流 | 未实现 | 易受高频请求冲击 |
| 高风险接口禁用开关 | 未实现 | `/raw` 与 `wait-message` 无公网降级能力 |
| 多 API Key 隔离 | 未实现 | 一把 Key 读取全实例邮箱 |
### 7.3 后续安全控制层设计
P1 建议新增一个“**公网安全控制子模块**”,统一放在 security / middleware 层处理:
职责:
1. 判断当前是否 `public_mode`
2. 获取真实客户端 IP
3. 做 IP 白名单校验
4. 做接口分级控制
5. 做单 IP / 单 Key 限流
建议逻辑顺序:
```text
api_key_required
↓
public_mode guard
↓
IP whitelist guard
↓
feature restriction guard
↓
rate limit guard
↓
controller
```
---
## 八、为什么这个模块要这样拆
### 8.1 不把所有逻辑放进 Controller
如果把参数解析、账号查询、读取回退、错误码映射、提取逻辑、轮询逻辑都塞进 controller,会导致:
1. 文件膨胀
2. 测试困难
3. 后续 P1/P2 扩展时难以插入安全控制
4. 同类能力无法复用
因此当前必须保留 `external_api.py` 作为独立编排层。
### 8.2 不直接新造一套邮件读取模块
因为现有项目已经有:
- Graph 读取
- Outlook IMAP 回退
- Generic IMAP
- 验证码提取器
- 设置仓储
所以架构上正确做法是“**编排复用**”,不是“**平行重造**”。
### 8.3 为什么 `wait-message` 不应该长期放在同步请求线程
因为它当前是:
- 同步轮询
- 请求线程阻塞
- 对单 worker 部署不友好
所以它在架构上只能算 P0 临时实现,不应该被当成长期公网能力模型。
---
## 九、分阶段架构演进
### 9.1 P0 架构
目标:
- 先把受控私有接入闭环做完整
模块状态:
- M1-M8 完成
- M9-M12 不进入当前实现
### 9.2 P1 架构
目标:
- 在不改业务接口路径的前提下补公网防护
新增模块:
1. 公网模式控制子模块
2. 白名单与限流子模块
3. 动态健康探测子模块
主要变更点:
- `security/auth.py` 或相邻安全层增加扩展 guard
- `controllers/system.py` 返回更多探测结果字段
- `/raw` 与 `/wait-message` 增加分级控制
### 9.3 P2 架构
目标:
- 支撑多个调用方和更高稳定性要求
新增模块:
1. 后台轮询 / 事件缓存子模块
2. 多 API Key 权限模型子模块
3. 调用方配额与吊销管理子模块
主要变更点:
- `wait-message` 从同步等待转向后台轮询或缓存查询
- 配置模型从单 `external_api_key` 升级为多 Key 结构
- 审计日志从“记录请求”升级为“记录调用方行为”
---
## 十、推荐目录与职责归属
### 10.1 当前目录归属
| 模块 | 建议目录 | 当前状态 |
|------|----------|----------|
| 外部路由 | `outlook_web/routes/` | 已在现有目录 |
| 外部 controller | `outlook_web/controllers/` | 已在现有目录 |
| 外部编排 service | `outlook_web/services/` | 已新增 `external_api.py` |
| 安全控制 | `outlook_web/security/` | 已有 `api_key_required()` |
| 仓储配置 | `outlook_web/repositories/` | 已复用 |
| 审计 | `outlook_web/audit.py` | 已复用 |
### 10.2 后续建议新增的归属
P1/P2 如继续演进,建议保持以下结构:
| 模块 | 建议文件 / 目录 | 说明 |
|------|------------------|------|
| 公网模式控制 | `outlook_web/security/external_api_guard.py` | 独立承接公网模式控制 |
| 限流能力 | `outlook_web/security/rate_limit.py` | 避免继续堆进 `auth.py` |
| 动态健康探测 | `outlook_web/services/external_probe.py` | 统一探测 Graph / IMAP 可读性 |
| 后台轮询 | `outlook_web/services/external_message_cache.py` 或 worker 目录 | 作为 P2 异步化承载点 |
| 多 Key 管理 | `outlook_web/repositories/external_api_keys.py` | 为权限模型扩展预留仓储边界 |
---
## 十一、实施结论
### 11.1 当前这个新模块已经做好的部分
目前从架构角度看,已经做好的部分是:
1. 路由入口已建好
2. API Key 鉴权已建好
3. 外部 controller 已建好
4. 外部编排 service 已建好
5. 读取适配与提取能力已接好
6. 设置页和配置链路已接好
7. 审计与文档链路已接好
基于当前实现状态,可以判断 **P0 架构已经基本成型**。
### 11.2 现在还需要哪几个部分
如果下一阶段要继续做,建议优先顺序如下:
1. **P1 第一优先级**
- 公网模式控制
- IP 白名单
- 限流
- 高风险接口分级
2. **P1 第二优先级**
- 动态健康探测
- `health` / `account-status` 增强
3. **P2 第一优先级**
- `wait-message` 解耦
- 后台轮询 / 缓存 / worker
4. **P2 第二优先级**
- 多 API Key
- Key 级邮箱范围授权
- 吊销、轮换、配额
### 11.3 最终建议
当前不要再把重点放在“新增更多外部业务接口”上,而应该按下面两条主线继续演进:
1. **鉴权收敛主线**
- 从单 `X-API-Key` 逐步演进到公网模式、白名单、限流、多 Key、范围授权
2. **轮询解耦主线**
- 从同步 `wait-message` 逐步演进到后台轮询、缓存、异步等待模型
这两条主线才是该模块后续架构升级的核心。
@@ -33,6 +33,8 @@
3. **优先复用现有读取链路**:Graph → IMAP(New) → IMAP(Old) 的回退策略保持一致。
4. **不引入复杂多租户设计**:首版采用单实例单 `external_api_key` 模式。
5. **敏感配置不明文回显**:设置页中仅展示脱敏后的开放 API Key 状态。
6. **首版定位为受控开放接口**:面向本地化部署与单可信调用方,不以“可直接公网暴露的通用开放平台”作为本版交付目标。
7. **首版不承诺公网级防护与高并发能力**:`wait-message` 为同步轮询实现,IP 白名单、配额与开放平台级限流不在首版闭环内。
### 1.3 Out of Scope
@@ -41,6 +43,7 @@
- 附件下载与附件元数据开放
- 外部删除邮件、标记已读、移动邮件等写操作
- 通用开放平台能力(限流配额、租户体系、开发者门户)
- 公网模式下的完整安全治理(来源白名单、接口分级、调用方配额)
---
@@ -106,6 +109,220 @@
| `outlook_web/services/telegram_push.py` | 推送功能与本次开放接口解耦 |
| `outlook_web/security/csrf.py` | 对外 GET 接口无需新增 CSRF 逻辑 |
### 3.3 功能修改范围结论
本功能不是“新增几条 API 路由”即可交付,而是一次跨越**鉴权层、路由层、邮件读取服务层、系统自检层、设置页与文档层**的受控开放能力补齐。
从范围上看,本次需要明确区分三类边界:
1. **首版必须落地的范围**
- 让外部调用方可以通过 `X-API-Key` 安全访问只读能力。
- 让管理员可以在设置页配置、更新、清空 `external_api_key`。
- 让开放接口复用现有 Graph → IMAP(New) → IMAP(Old) 读取链路,而不是单独重写读取逻辑。
- 让系统对外暴露最小可联调能力:消息列表、最新邮件、详情、验证码、验证链接、基础健康信息。
- 让文档口径与实现一致,明确“受控私有接入”而非“直接公网开放”。
2. **后续条件成立后再推进的范围**
- 仅在需要公网暴露、半开放部署或多调用方接入时,再引入公网模式、IP 白名单、限流、多 Key、范围授权等能力。
- `wait-message` 的异步化与后台轮询解耦,属于后续可用性增强主线,不作为首版闭环前置条件。
3. **本轮明确不进入的范围**
- 不改账号导入导出主流程。
- 不改内部登录态 API URL 与主体行为。
- 不增加邮件写操作、附件下载、Webhook 推送、多租户开发者平台能力。
因此,本 FD 对“修改范围”的最终定义是:
- **P0 首版交付范围**:受控私有接入闭环。
- **P1 扩展范围**:公网模式外围防护与接口分级。
- **P2 演进范围**:轮询解耦与权限模型升级。
### 3.4 首版必须修改范围
首版如果要满足 PRD 中“开放验证码与邮件读取 API”的交付目标,以下模块必须修改,且这些修改彼此之间是联动关系,不建议拆成孤立需求分别处理。
| 模块 | 文件 | 是否必改 | 修改重点 | 说明 |
|------|------|----------|----------|------|
| 鉴权层 | `outlook_web/security/auth.py` | 是 | 新增 `api_key_required` | 建立外部 API 与后台 Session 登录态的隔离边界 |
| 外部邮件路由 | `outlook_web/routes/emails.py` | 是 | 注册 `/api/external/messages*`、`/verification-*`、`/wait-message` | 没有独立路由就无法形成对外能力入口 |
| 外部系统路由 | `outlook_web/routes/system.py` | 是 | 注册 `/api/external/health`、`/capabilities`、`/account-status` | 用于部署后的联调、自检与能力声明 |
| 邮件控制器 | `outlook_web/controllers/emails.py` | 是 | 新增外部 controller、参数校验、审计接入 | 负责把对外参数转换成内部读取请求 |
| 系统控制器 | `outlook_web/controllers/system.py` | 是 | 新增外部健康检查、能力说明、账号状态 | 负责补齐“能否接”的外部观测面 |
| 设置控制器 | `outlook_web/controllers/settings.py` | 是 | 保存、读取、脱敏返回 `external_api_key` | 没有设置接口就无法运营化维护 API Key |
| 设置仓储 | `outlook_web/repositories/settings.py` | 是 | 封装 `external_api_key` 获取、解密、脱敏辅助逻辑 | 避免 controller 分散写敏感配置逻辑 |
| DB 初始化 | `outlook_web/db.py` | 是 | 初始化 `external_api_key` 设置项 | 保证新部署实例可直接配置开放能力 |
| 外部服务层 | `outlook_web/services/external_api.py` | 是 | 统一响应包装、读信回退复用、筛选、等待逻辑 | 避免 `controllers/emails.py` 继续膨胀 |
| 提取器增强 | `outlook_web/services/verification_extractor.py` | 是 | 支持更稳定的验证码/链接提取结果结构 | 直接决定对外接口的可用性与接入成本 |
| 设置页模板 | `templates/index.html` | 是 | 增加“对外 API”配置区块 | 管理员需要可视化配置入口 |
| 设置页脚本 | `static/js/main.js` | 是 | 加载、保存、脱敏展示 `external_api_key` | 前端交互需要与设置接口对齐 |
| 测试 | `tests/test_external_api.py` | 是 | 覆盖鉴权、参数校验、读取回退、错误码 | 没有独立测试无法稳定演进开放接口 |
| 使用文档 | `README.md`、`docs/api.md`、本 FD/TDD/TEST 文档 | 是 | 统一首版定位、示例、限制说明 | 防止实现与文档口径背离 |
#### 3.4.1 首版必改范围的功能边界
以上必改范围进一步落到功能边界时,应覆盖以下六块:
1. **开放访问入口**
- 提供 `/api/external/*` 路由。
- 保持与内部 API 路径隔离,避免复用登录态鉴权。
2. **开放鉴权与配置**
- 新增 `external_api_key` 的存储、读取、脱敏展示。
- 强制所有开放接口只接受 `X-API-Key`,不接受 query 参数中的 `api_key`。
3. **开放读取能力**
- 支持消息列表、最新消息、详情、RAW、验证码、验证链接、等待消息。
- 复用现有读取链路,不改变内部 Graph / IMAP 回退策略。
4. **最小系统自检能力**
- 提供健康状态、能力列表、账号状态。
- 允许调用方在接入前快速判断服务是否已配置与可调用。
5. **管理后台可维护性**
- 设置页可查看是否已配置 API Key。
- 支持手动更新或清空,不明文回显。
6. **文档与测试闭环**
- 文档明确调用方式、返回结构、限制条件。
- 测试至少覆盖成功、失败、未配置、参数错误、未命中等关键分支。
#### 3.4.2 首版具体功能修改项
如果进一步按“功能”而不是“文件”来拆,本次 P0 首版实际需要修改或新增的功能点如下。
| 功能域 | 功能项 | 修改类型 | 是否必改 | 说明 |
|------|--------|----------|----------|------|
| 外部接入 | 外部接口统一前缀 `/api/external/*` | 新增 | 是 | 建立与内部登录态 API 的路径隔离 |
| 外部接入 | 外部接口统一 `X-API-Key` 鉴权 | 新增 | 是 | 不依赖 Session / Cookie |
| 外部接入 | 外部接口统一响应结构 `success/code/message/data` | 新增 | 是 | 与内部接口错误载荷隔离 |
| 自动化注册 | 获取验证码 | 新增 | 是 | 直接服务自动化注册主场景 |
| 自动化注册 | 获取验证链接 | 新增 | 是 | 直接服务自动化注册主场景 |
| 自动化注册 | 获取最新匹配邮件 | 新增 | 是 | 作为验证码/链接之外的脚本兜底能力 |
| 自动化注册 | 等待新邮件到达 `wait-message` | 新增 | 是 | 作为 P0 受控场景下的同步等待能力 |
| 人工排查 | 获取邮件列表 | 新增 | 是 | 自动提取失败后的排查入口 |
| 人工排查 | 获取邮件详情 | 新增 | 是 | 查看正文、HTML、基础元信息 |
| 人工排查 | 获取 RAW 内容 | 新增 | 是 | 作为高风险但必要的调试能力 |
| 自检联调 | 外部健康检查 | 新增 | 是 | 供调用方快速判断服务与 DB 是否可用 |
| 自检联调 | 外部能力说明 | 新增 | 是 | 供接入方确认当前实例支持哪些能力 |
| 自检联调 | 外部账号状态检查 | 新增 | 是 | 供调用方提前判断目标邮箱是否存在、基础条件是否满足 |
| 提取能力 | 验证码规则可配置 | 增强 | 是 | 支持 `code_regex`、`code_length`、`code_source` |
| 提取能力 | 验证链接高置信优先级 | 增强 | 是 | 优先 `verify/confirm/activate/validation` 一类链接 |
| 配置能力 | `external_api_key` 落库 | 新增 | 是 | 形成开放接口最小配置闭环 |
| 配置能力 | `external_api_key` 脱敏回显 | 新增 | 是 | 避免设置页泄露敏感信息 |
| 配置能力 | 设置页录入 / 清空 API Key | 新增 | 是 | 让管理员无需直接改 DB |
| 观测能力 | 外部接口审计日志 | 新增 | 是 | 记录开放接口访问行为与错误码 |
| 文档能力 | 对外 API 文档 | 新增 | 是 | 让调用方有正式文档可对照 |
| 文档能力 | 安全边界说明 | 新增 | 是 | 明确当前版本不建议直接公网暴露 |
这些功能里,真正属于“当前已经在代码中有基础,但需要扩展”的主要是三块:
1. **邮件读取链路复用**
- 现有内部读信能力已经存在
- 本次要做的是把它抽象成可对外复用的编排能力
2. **验证码提取器增强**
- 现有提取逻辑已存在
- 本次要补的是参数化规则和更稳定的结果结构
3. **设置页与设置接口扩展**
- 现有设置系统已存在
- 本次要补的是 `external_api_key` 的配置闭环
#### 3.4.3 按现有功能视角看,需要改哪些已有功能
除了新增外部能力,本次还会影响现有几个“已有功能”的表现或职责边界,需要在 FD 中明确。
| 已有功能 | 当前状态 | 本次需要修改的点 | 修改目的 |
|----------|----------|------------------|----------|
| 内部邮件读取 | 仅供后台登录态使用 | 抽公共读取逻辑,供 external service 复用 | 避免重复实现两套读信逻辑 |
| 内部验证码提取 | 以内部接口为主 | 提取器支持对外参数化调用 | 提升对外自动化场景可用性 |
| 系统健康检查 | 管理员登录后查看 | 增加外部轻量健康检查与能力查询 | 支持部署后联调 |
| 设置页 | 管理后台配置系统参数 | 增加 `external_api_key` 配置区块 | 支持运营维护 |
| 审计日志 | 已覆盖部分后台行为 | 增加外部 API 审计事件 | 支持问题定位与后续安全治理 |
需要特别强调:
- **内部 API 本身不是这次的改造目标**,只是被复用。
- **账号管理主流程不是这次的改造目标**,只是被外部接口读取账号信息。
- **系统健康检查的管理员诊断能力与外部轻量自检能力必须保持分层**,不能混成一个接口。
#### 3.4.4 功能修改优先级
为了保证实现顺序和 PRD 一致,功能修改优先级建议如下:
| 优先级 | 功能项 | 说明 |
|--------|--------|------|
| P0-1 | `external_api_key` 配置闭环 + `api_key_required` | 没有这一层,所有外部能力都无法成立 |
| P0-2 | `/api/external/messages*`、`/verification-*`、`/wait-message` | 核心业务能力闭环 |
| P0-3 | `/api/external/health`、`/capabilities`、`/account-status` | 支持部署自检与联调 |
| P0-4 | 提取器增强、统一响应结构、审计日志 | 提高可用性与可观测性 |
| P0-5 | 设置页、API 文档、测试文档 | 完成运营与交付闭环 |
#### 3.4.5 当前阶段明确不需要在 FD 中列为必改功能的项
以下功能虽然会在后续出现,但当前不应被写成 P0 必改项:
| 功能项 | 原因 | 对应阶段 |
|--------|------|----------|
| 公网模式开关 | 不属于当前受控私有接入闭环 | P1 |
| IP 白名单 | 不属于当前受控私有接入闭环 | P1 |
| 应用层限流 | 不属于当前受控私有接入闭环 | P1 |
| `/wait-message` 公网禁用逻辑 | 只在公网模式下成立 | P1 |
| `/raw` 公网禁用逻辑 | 只在公网模式下成立 | P1 |
| 上游真实探测 | 当前 `health` / `account-status` 仅做轻量自检 | P1 |
| 多 API Key | 超出单可信调用方边界 | P2 |
| Key 级邮箱授权 | 超出单可信调用方边界 | P2 |
| `wait-message` 后台轮询解耦 | 当前首版允许同步轮询实现 | P2 |
### 3.5 条件后改范围
以下内容确实重要,但它们服务的是“公网暴露能力”或“长期平台化能力”,不应反向阻塞首版受控私有接入闭环。
| 范围 | 建议阶段 | 触发条件 | 说明 |
|------|----------|----------|------|
| 公网模式开关 | P1 | 需要通过公网或半开放网络提供 external API | 用于切换“私有接入”与“公网受限接入”两套规则 |
| IP 白名单 | P1 | 调用来源已知且希望只允许固定出口访问 | 适合作为公网前的第一层保护 |
| 请求限流 | P1 | 存在多个脚本、高频调用或外部扫描风险 | 保护单实例,避免挤占主线程 |
| 高风险接口分级 | P1 | 开始考虑公网暴露 | 公网模式默认禁用 `/wait-message` 与 `/raw` |
| 动态上游探测 | P1 | 需要更真实的“可读性”证明 | 让 `/health`、`/account-status` 不只停留在静态字段层面 |
| `wait-message` 解耦 | P2 | 并发等待请求增多,或需要稳定 SLA | 从同步轮询演进到后台轮询 / 缓存 / worker |
| 多 API Key | P2 | 一个实例对应多个调用方 | 为不同调用方建立独立身份 |
| Key 级邮箱授权范围 | P2 | 不同调用方只能读取部分邮箱 | 解决“一把 Key 读全实例”的权限过粗问题 |
| Key 启停、轮换、吊销 | P2 | 进入长期运营阶段 | 解决密钥泄漏与变更管理问题 |
| 调用方配额与审计增强 | P2 | 需要平台化运营 | 支持定位具体调用方与消耗行为 |
#### 3.5.1 为什么这些不放进首版闭环
原因不是这些能力不重要,而是它们对应的是另一层目标:
- 首版解决的是“**能否在受控环境稳定接入**”。
- P1 解决的是“**能否在半开放网络下相对安全地开放**”。
- P2 解决的是“**能否把这套能力做成长期可运营的外部平台接口**”。
如果在首版同时追求三层目标,改动面会从“开放接口能力补齐”扩大到“安全平台、调用方管理、异步架构重构”,风险和周期都会显著上升。
### 3.6 明确不改范围
为了防止需求蔓延,本功能以下范围明确不在本轮 FD 的实施边界内:
| 不改范围 | 说明 |
|----------|------|
| `outlook_web/routes/accounts.py`、`outlook_web/controllers/accounts.py` 主流程 | 不因为开放 API 反向重构账号管理模块 |
| 内部 API 路径与行为 | 保持 `/api/emails/*`、`/api/email/*` 等内部接口原有行为不变 |
| `telegram_push` 推送链路 | 与外部读取接口解耦,不纳入此次改造 |
| 附件下载、附件元数据开放 | 首版只聚焦验证码与邮件读取正文能力 |
| 邮件删除、标记已读、移动邮件 | 不开放写操作,避免权限与副作用扩大 |
| Webhook / 回调通知 | 不建设反向推送能力,仍由调用方主动拉取 |
| 多租户、开发者门户、套餐配额 | 不把当前项目升级为开放平台产品 |
| CSRF 主体逻辑 | 对外接口为 API Key 保护的 GET 只读接口,不引入额外 CSRF 设计 |
### 3.7 分阶段修改范围矩阵
为了让 FD、BUG、TDD 三份文档口径一致,功能修改范围按阶段收敛如下:
| 阶段 | 范围定位 | 必须修改 | 可暂缓修改 | 验收口径 |
|------|----------|----------|------------|----------|
| P0 | 受控私有接入闭环 | API Key 鉴权、开放路由、开放 controller、外部 service、设置页、基础自检、文档、测试 | 公网模式、白名单、限流、多 Key、异步轮询 | 单实例、本地部署、单可信调用方可稳定接入 |
| P1 | 半开放部署增强 | 公网模式、IP 白名单、限流、高风险接口分级、动态上游探测 | 多 Key、邮箱范围授权、后台轮询重构 | 可以在受限公网环境下谨慎开放 |
| P2 | 平台化与可用性演进 | `wait-message` 解耦、多 Key、范围授权、吊销轮换、配额审计 | 开发者门户等更重平台能力 | 能支撑多调用方和更稳定的外部服务能力 |
进一步拆到实现层时,推荐按以下顺序推进:
1. 先完成 P0 文档口径与代码闭环,确保“受控私有接入”真实可用。
2. 再按部署场景决定是否进入 P1,而不是预先把所有公网治理一次性做完。
3. 当调用方数量、并发量、权限复杂度明显上升时,再启动 P2 的架构升级。
---
## 四、路由设计
@@ -115,6 +332,7 @@
- 开放接口统一挂在 `/api/external` 前缀下
- 与现有内部接口语义对齐,但路径与鉴权方式分离
- 首版以 `GET` 为主,避免复杂副作用
- 当前默认按“受控私有接入”设计,而非直接公网暴露设计
### 4.2 新增路由清单
@@ -356,7 +574,7 @@ if "external_api_key" in data:
- 封装统一错误码与统一响应体
- 封装等待轮询逻辑
#### 7.1.2 推荐函数签名
#### 7.1.2 当前实现应对齐的函数签名
```python
def list_messages_for_external(
@@ -368,8 +586,8 @@ def list_messages_for_external(
from_contains: str = "",
subject_contains: str = "",
since_minutes: int | None = None,
) -> dict:
"""获取对外邮件列表。"""
) -> tuple[list[dict], str]:
"""获取对外邮件列表,并返回 method 标签。"""
def get_latest_message_for_external(... ) -> dict:
@@ -380,18 +598,19 @@ def get_message_detail_for_external(*, email_addr: str, message_id: str) -> dict
"""返回完整邮件详情。"""
def extract_verification_code_for_external(... ) -> dict:
"""返回验证码提取结果。"""
def get_verification_result(... ) -> dict:
"""返回验证码 / 验证链接统一提取结果。"""
def extract_verification_link_for_external(... ) -> dict:
"""返回验证链接提取结果。"""
def wait_message_for_external(... ) -> dict:
def wait_for_message(... ) -> dict:
"""服务端轮询等待新邮件。"""
```
补充说明:
- 当前 `/raw` 接口没有独立的 `get_message_raw_for_external()` service,而是复用 `get_message_detail_for_external()` 后裁剪 `raw_content`
- 当前 controller 不拆成 `extract_verification_code_for_external()` / `extract_verification_link_for_external()` 两个 service,而是统一调用 `get_verification_result()`
### 7.2 与现有服务的复用关系
| 目标能力 | 复用模块 | 说明 |
@@ -401,19 +620,24 @@ def wait_message_for_external(... ) -> dict:
| 解析验证码/链接 | `services/verification_extractor.py` | 在此基础上增强自定义规则 |
| 健康检查 | `controllers/system.py` | 对外健康检查可做轻量版本封装 |
### 7.3 建议的重构边界
### 7.3 当前重构边界
为避免 controller 继续变大,建议在 `controllers/emails.py` 中抽出如下内部辅助函数到 `services/external_api.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 list_messages_for_external(... ) -> tuple[list[dict], str]:
"""内部直接承接 Graph / IMAP 列表读取回退。"""
def _read_email_detail_with_fallback(account: dict, message_id: str, folder: str = "inbox") -> dict:
"""统一封装邮件详情读取链路。"""
def get_message_detail_for_external(... ) -> dict:
"""内部直接承接 Graph / IMAP 详情读取回退。"""
```
设计约束:
- 不要求为了“分层好看”再额外拆 `_read_emails_with_fallback()` / `_read_email_detail_with_fallback()`
- 只要 controller 与 service 的职责边界清晰,就符合当前 PRD 的 P0 范围
### 7.4 `verification_extractor.py` 增强设计
当前 `verification_extractor.py` 已支持:
@@ -933,6 +1157,9 @@ function generateExternalApiKey() {
| `wait-message` 阻塞请求线程 | 并发能力下降 | 限制最大 120 秒,默认 30 秒 |
| 外部接口与内部响应结构不一致 | 接入混乱 | 开放接口统一使用 `success/code/message/data` |
| 现有验证码提取误判 | 自动化稳定性不足 | 引入 `code_regex`、`code_length`、`code_source` 可配置项 |
| 单 `external_api_key` 读取全实例邮箱 | 权限边界过粗 | 首版仅按单可信调用方交付,后续再引入多 Key / 范围授权 |
| 缺少公网防护(白名单 / 限流 / 配额) | 公网暴露风险高 | 当前不以公网开放为首版目标,后续通过“公网模式”分阶段补齐 |
| 自检接口偏静态 | 可能“自检成功但实际不可读” | 后续补充上游探测与动态健康指标 |
### 15.2 关键设计决策
@@ -941,6 +1168,8 @@ function generateExternalApiKey() {
3. **开放接口优先返回统一简洁结构**,不直接透出内部 `build_error_payload()` 的嵌套结构。
4. **`account-status` 为轻量检查,不做真实拉信**,避免把自检接口做成慢接口。
5. **`wait-message` 首版采用同步轮询实现**,不引入异步任务体系。
6. **首版开放 API 的正式定位为“受控私有接入”**,文档与实现均不以“直接公网暴露”作为验收标准。
7. **若未来支持公网暴露**,优先补充公网模式、来源白名单、接口分级与动态探测,而不是继续扩大开放接口集合。
---
@@ -65,6 +65,8 @@
- **设置页不回显明文 API Key**:仅返回 `*_set`、`*_masked`
- **日志中不允许输出明文 API Key**
- **查询参数中不接受 `api_key`**:根项目不沿用示例项目的 query 传 key 方案
- **首版不以公网开放为目标验收**:只保证本地化部署、单可信调用方、受控访问环境下的可用性
- **首版不内建开放平台级防护**:来源白名单、调用方配额、多 API Key 隔离不在本次实现闭环内
### 2.3 向后兼容原则
@@ -73,6 +75,17 @@
- Graph → IMAP(New) → IMAP(Old) 的读取回退顺序保持不变
- 现有 `verification_extractor.py` 默认提取逻辑不破坏,仅在外部接口场景补充参数化入口
### 2.4 对公网暴露的阶段性约束
- 当前 `/api/external/*` 的设计定位为**受控私有接入接口**,不等价于“可直接公网开放的通用 API 平台”
- `wait-message` 使用同步轮询,请求线程会被占用;该接口在公网模式下应视为高风险接口
- `/api/external/messages/{message_id}/raw` 会返回高敏感原始内容;该接口在公网模式下应默认限制
- 若后续引入公网模式,应在应用层增加:
- `public_mode` 配置
- 来源 IP 白名单
- 高风险接口禁用或降级
- 动态上游探测结果返回
---
## 3. 总体技术架构与数据流
@@ -92,18 +105,18 @@ Repository: accounts_repo.get_account_by_email(email)
↓
Service: external_api.get_latest_message_for_external(...)
↓
Service: _read_emails_with_fallback(account, folder, skip=0, top=N)
Service: list_messages_for_external(...) 内部执行 Graph / IMAP 回退
├─ graph_service.get_emails_graph(...)
├─ imap_service.get_emails_imap_with_server(..., IMAP_SERVER_NEW)
└─ imap_service.get_emails_imap_with_server(..., IMAP_SERVER_OLD)
↓
Service: _read_email_detail_with_fallback(account, message_id, folder)
Service: get_message_detail_for_external(...) 内部执行详情读取回退
↓
Service: verification_extractor.extract_verification_info_with_options(...)
↓
Service: ok()/fail() 统一包装
↓
Audit: log_audit("external_api_access", ...)
Audit: external_api.audit_external_api_access(...)
↓
返回 JSON
```
@@ -167,6 +180,54 @@ repositories/settings.py.set_setting('external_api_key', encrypted)
| `tests/test_external_api.py` | **新增** | 契约/集成测试 |
| `tests/test_settings_external_api_key.py` | **新增/可选** | 设置项测试 |
### 4.1 文件级真实技术改造点
为了避免 TDD 只停留在抽象分层,这里按“文件 -> 需要真实修改什么”进一步展开。
| 文件 | 真实修改点 | 技术细节 |
|---|---|---|
| `outlook_web/db.py` | 初始化默认设置项 | 在 `init_db()` 的 settings 初始化逻辑中补 `external_api_key`,保持幂等,不增加 schema 版本 |
| `outlook_web/repositories/settings.py` | 封装开放 API Key 读取能力 | 新增 `get_external_api_key()`、`get_external_api_key_masked()`,读取时统一走 `decrypt_data()` |
| `outlook_web/security/auth.py` | 新增外部鉴权装饰器 | 增加 `api_key_required()`,只接受 `X-API-Key`,用 `secrets.compare_digest()` 做常量时间比较 |
| `outlook_web/controllers/settings.py` | 扩展设置读写 | `GET /api/settings` 返回 `external_api_key_set`、`external_api_key_masked`;`PUT /api/settings` 支持加密保存与清空 |
| `outlook_web/routes/emails.py` | 注册外部消息与验证接口 | 在现有 emails blueprint 下追加 `/api/external/messages*`、`/verification-*`、`/wait-message` |
| `outlook_web/routes/system.py` | 注册外部系统接口 | 追加 `/api/external/health`、`/capabilities`、`/account-status` |
| `outlook_web/controllers/emails.py` | 增加外部 controller 入口 | 增加参数解析、调用 external service、统一错误响应、外部审计日志 |
| `outlook_web/controllers/system.py` | 增加外部自检接口 | 对外返回轻量健康状态、能力列表、账号状态,不复用登录态接口 |
| `outlook_web/services/external_api.py` | 新建编排层 | 承担账号校验、Graph/IMAP 回退、列表过滤、详情读取、验证码提取结果组装、等待轮询 |
| `outlook_web/services/verification_extractor.py` | 增强提取器参数化能力 | 增加 `extract_verification_info_with_options()`,支持 `code_regex` / `code_length` / `code_source` |
| `templates/index.html` | 增加设置页入口 | 新增 `settingsExternalApiKey` 输入框和 `externalApiKeyHint` 提示文案区域 |
| `static/js/main.js` | 对接设置页读写 | `loadSettings()` 回填脱敏值;`saveSettings()` 通过 `dataset.maskedValue` / `dataset.isSet` 避免把占位符写回 DB |
| `tests/test_external_api.py` | 覆盖主链路测试 | 覆盖鉴权、列表、详情、RAW、验证码、验证链接、wait-message、system 接口 |
| `tests/test_settings_external_api_key.py` | 覆盖设置与加密兼容 | 覆盖设置保存、脱敏回显、明文/加密兼容与清空行为 |
### 4.2 文件之间的技术依赖
这些文件不是独立修改,而是一条完整技术链路:
```text
db.py
↓ 提供 external_api_key 默认配置
repositories/settings.py
↓ 提供读取 / 解密 / 脱敏能力
security/auth.py
↓ 读取 external_api_key 做鉴权
controllers/settings.py
↓ 提供管理员配置入口
routes/*.py + controllers/*.py
↓ 暴露 /api/external/*
services/external_api.py
↓ 编排 graph / imap / extractor
tests/*
↓ 验证整条链路
```
关键结论:
- `api_key_required()` 不能脱离 `repositories/settings.py` 独立实现。
- 外部 controller 不能脱离 `services/external_api.py` 直接堆读取逻辑。
- 前端设置页必须和 `controllers/settings.py` 的脱敏规则一致,否则会把占位符误写回数据库。
---
## 5. 设置与鉴权技术细节
@@ -223,21 +284,33 @@ def get_external_api_key_masked() -> str:
建议实现:
```python
from outlook_web.security.crypto import decrypt_data, is_encrypted
from outlook_web.security.crypto import decrypt_data
def get_external_api_key() -> str:
value = get_setting("external_api_key", "")
if not value:
return ""
if is_encrypted(value):
try:
return decrypt_data(value)
except Exception:
return ""
return value
try:
# decrypt_data() 内部已兼容 enc: 加密值和历史明文值
return decrypt_data(value)
except Exception:
return ""
```
真实实现约束:
- 不在 repository 层重复判断 `enc:` 前缀,直接复用 `decrypt_data()`
- `decrypt_data()` 已兼容:
- `enc:` 开头的加密值
- 历史明文值
- 解密失败时返回空字符串,避免外部接口鉴权出现 500
补充说明:
- 当前 `controllers/settings.py` 实际使用的是 `get_external_api_key()` + 本地 `_mask_secret_value()`
- `get_external_api_key_masked()` 仍建议保留在 repository 层,供后续其他调用点复用
### 5.4 `api_key_required()` 技术设计
文件:`outlook_web/security/auth.py`
@@ -245,6 +318,8 @@ def get_external_api_key() -> str:
新增:
```python
import secrets
from outlook_web.repositories.settings import get_external_api_key
@@ -269,7 +344,7 @@ def api_key_required(f):
"data": None,
}), 403
if api_key != stored_key:
if not secrets.compare_digest(str(api_key), str(stored_key)):
return jsonify({
"success": False,
"code": "UNAUTHORIZED",
@@ -287,6 +362,53 @@ def api_key_required(f):
- 不跳转登录页面
- 不读取 query 参数中的 `api_key`
- 所有开放接口 controller 必须直接使用 `@api_key_required`
- 使用 `secrets.compare_digest()`,避免直接字符串比较带来的时序差异
- 鉴权失败统一返回简化错误结构,不携带内部 trace 细节
### 5.6 公网模式扩展设计(后续阶段)
为避免当前开放接口被误用为“可直接公网暴露”的能力,建议在后续阶段补充以下配置项:
| 配置项 | 默认值 | 说明 |
|---|---|---|
| `external_api_public_mode` | `false` | 是否启用公网模式 |
| `external_api_ip_whitelist` | `""` | 允许访问 external API 的 IP / CIDR 列表 |
| `external_api_disable_wait_message` | `true` | 公网模式下默认禁用长轮询接口 |
| `external_api_disable_raw_content` | `true` | 公网模式下默认禁用 RAW 内容接口 |
| `external_api_rate_limit_per_minute` | `60` | 公网模式下对单 IP 或单 API Key 的分钟级限流 |
建议实现约束:
1. `public_mode=false` 时,保持当前受控私有接入行为。
2. `public_mode=true` 时,开放接口应额外经过:
- IP 白名单检查
- 高风险接口禁用检查
- 请求频率限制
3. `api_key_required()` 继续保留为必经入口,不与公网模式互斥。
建议后续新增辅助函数:
```python
def enforce_external_api_public_controls(
*,
endpoint: str,
client_ip: str,
) -> None:
"""
在 public_mode 启用时执行:
1. 白名单校验
2. 高风险接口禁用
3. 限流校验
"""
```
错误码建议扩展:
| code | status | 含义 |
|---|---|---|
| `IP_NOT_ALLOWED` | 403 | 来源 IP 不在白名单 |
| `FEATURE_DISABLED` | 403 | 当前模式下接口被禁用 |
| `RATE_LIMITED` | 429 | 请求频率超限 |
---
@@ -337,6 +459,7 @@ bp.add_url_rule(
说明:
- 统一放在 `emails` Blueprint 内,避免额外新建 Blueprint
- 与内部邮件能力聚合,减少初始化改动
- 后续如引入公网模式,不调整路由 URL,只在 controller / security 层增加额外约束
### 6.2 `outlook_web/routes/system.py`
@@ -389,46 +512,76 @@ def api_external_get_verification_link() -> Any: ...
def api_external_wait_message() -> Any: ...
```
### 7.2 Controller 只做三件事
### 7.2 Controller 当前真实职责
1. 解析并校验参数
2. 调用 `services/external_api.py`
3. 写审计日志并返回统一响应
### 7.3 参数解析辅助函数
后续如引入公网模式,可扩展为四件事:
建议在 `controllers/emails.py` 内新增:
1. 执行 `@api_key_required`
2. 执行 `enforce_external_api_public_controls()`
3. 解析参数并调用 service
4. 审计并返回统一响应
### 7.3 当前真实参数解析辅助函数
当前真实实现不是文档早期草稿中的 `_parse_external_args()`,而是:
```python
def _parse_external_args() -> dict:
folder = (request.args.get("folder", "inbox") or "inbox").strip().lower()
def _parse_external_common_args(*, default_since_minutes: int | None = None) -> dict:
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 ValueError("folder 参数无效")
raise external_api_service.InvalidParamError("folder 参数无效")
top = int(request.args.get("top", 20))
if top < 1 or top > 50:
raise ValueError("top 参数无效")
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(request.args.get("skip", 0))
skip = _int_arg("skip", 0)
top = _int_arg("top", 20)
if skip < 0:
raise ValueError("skip 参数无效")
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 = default_since_minutes
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": (request.args.get("email", "") or "").strip(),
"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": request.args.get("since_minutes"),
"code_length": (request.args.get("code_length", "") or "").strip(),
"code_regex": (request.args.get("code_regex", "") or "").strip(),
"code_source": (request.args.get("code_source", "all") or "all").strip().lower(),
"timeout_seconds": request.args.get("timeout_seconds"),
"poll_interval": request.args.get("poll_interval"),
"since_minutes": since_minutes,
}
```
补充说明:
- `verification-code`、`verification-link` 会调用 `_parse_external_common_args(default_since_minutes=10)`,默认只看最近 10 分钟邮件。
- `messages`、`messages/latest`、`message_detail`、`raw`、`wait-message` 调用 `_parse_external_common_args()`,默认不强制 `since_minutes`。
- `code_length`、`code_regex`、`code_source`、`timeout_seconds`、`poll_interval` 由各自 controller 单独解析,不放进通用解析器。
### 7.4 参数校验约束
| 参数 | 校验规则 |
@@ -439,32 +592,34 @@ def _parse_external_args() -> dict:
| `skip` | `>=0` |
| `since_minutes` | `>=1` |
| `timeout_seconds` | `1-120` |
| `poll_interval` | `1-30` 且 `< timeout_seconds` |
| `poll_interval` | `>=1` 且 `<= timeout_seconds` |
| `code_source` | `subject/content/html/all` |
| `code_length` | 匹配 `^\d+-\d+$` |
### 7.5 审计日志实现约定
### 7.5 审计日志真实实现约定
每个开放接口在 controller 末尾统一记录:
当前 controller 并不直接调用 `log_audit(...)`,而是统一调用:
```python
log_audit(
external_api_service.audit_external_api_access(
action="external_api_access",
resource_type="external_api",
resource_id=email_addr,
details=json.dumps(
{
"path": request.path,
"result": "success",
"method": payload_method,
"error_code": "",
},
ensure_ascii=False,
),
email_addr=email_addr,
endpoint="/api/external/messages",
status="ok",
details={"method": method, "count": len(filtered)},
)
```
失败分支同样记录 `result=failed` 与 `error_code`。
`audit_external_api_access()` 在 `services/external_api.py` 中再统一做两件事:
1. 组装 `{"endpoint": ..., "status": ..., ...}` 的安全日志对象
2. 调用 `log_audit(action, "external_api", email_addr, details_text)`
真实实现约束:
- controller 成功和失败分支都会记审计日志
- 失败时一般只写 `code` 与异常类型,不写敏感请求体
- service 层已明确标注“避免日志中输出敏感信息(如 API Key)”
---
@@ -474,26 +629,33 @@ log_audit(
该模块负责承接开放接口通用逻辑,避免 `controllers/emails.py` 继续膨胀。
### 8.2 模块函数清单
### 8.2 模块函数清单(真实实现)
```python
def ok(data, message: str = "success") -> dict:
return {"success": True, "code": "OK", "message": message, "data": data}
def fail(code: str, message: str, status: int, data=None):
return jsonify({"success": False, "code": code, "message": message, "data": data}), status
def fail(code: str, message: str, *, data=None) -> dict:
return {"success": False, "code": code, "message": message, "data": data}
def list_messages_for_external(... ) -> dict: ...
def require_account(email_addr: str) -> dict: ...
def list_messages_for_external(... ) -> tuple[list[dict], str]: ...
def filter_messages(... ) -> list[dict]: ...
def get_latest_message_for_external(... ) -> dict: ...
def get_message_detail_for_external(... ) -> dict: ...
def get_message_raw_for_external(... ) -> dict: ...
def extract_verification_code_for_external(... ) -> dict: ...
def extract_verification_link_for_external(... ) -> dict: ...
def wait_message_for_external(... ) -> dict: ...
def get_verification_result(... ) -> dict: ...
def wait_for_message(... ) -> dict: ...
def audit_external_api_access(... ) -> None: ...
```
需要明确:
- 当前没有单独的 `get_message_raw_for_external()`,`/raw` controller 直接复用 `get_message_detail_for_external()` 后裁剪 `raw_content`
- 当前没有拆成 `extract_verification_code_for_external()` / `extract_verification_link_for_external()` 两个 service,controller 都走 `get_verification_result()`
- 当前 `fail()` 只返回字典,HTTP status 由 controller 决定
### 8.3 `list_messages_for_external()` 设计
输入:
@@ -501,41 +663,46 @@ def wait_message_for_external(... ) -> dict: ...
- `folder`
- `skip`
- `top`
补充说明:
- `from_contains`
- `subject_contains`
- `since_minutes`
输出:
以上三个过滤条件不在该函数签名里处理,而是由外层配合 `filter_messages()` 处理。
真实返回:
```python
{
"emails": [...],
"count": 2,
"has_more": False,
}
([message_summary, ...], "Graph API")
```
实现步骤:
1. `accounts_repo.get_account_by_email(email_addr)`
2. `_read_emails_with_fallback(account, folder, skip, top)`
3. 将读取结果统一映射成 `MessageSummary`
4. 按 `from_contains`、`subject_contains`、`since_minutes` 在 service 层二次过滤
5. 返回过滤后结果
1. `require_account(email_addr)` 做账号存在性和 email 合法性校验
2. 若 `account_type == "imap"`,直接走 `get_emails_imap_generic()`
3. 若是 Outlook 账号,按 `Graph API -> IMAP(New) -> IMAP(Old)` 回退
4. 使用 `_build_message_summary()` 统一映射字段
5. 返回 `(emails, method_label)`,过滤由 controller 再调用 `filter_messages()`
### 8.4 `get_latest_message_for_external()`
实现:
- 直接调用 `list_messages_for_external(..., skip=0, top=max(top, 20))`
- 若 `emails` 为空,抛 `MailNotFoundError`
- 返回 `emails[0]`
真实实现:
- 直接调用 `list_messages_for_external(email_addr=..., folder=..., skip=0, top=20)`
- 再调用 `filter_messages(...)`
- 若过滤后为空,抛 `MailNotFoundError`
- 为避免不同读取链路返回顺序不稳定,再按 `timestamp` 倒序排序后返回第一封
### 8.5 `get_message_detail_for_external()`
实现:
1. 获取账号
2. 调用 `_read_email_detail_with_fallback()`
3. 统一映射为:
真实实现分两条链路:
1. `account_type == "imap"`:调用 `get_email_detail_imap_generic()`
2. Outlook 账号:`graph_service.get_email_detail_graph()` 优先,失败后回退 `imap_service.get_email_detail_imap_with_server(..., IMAP_SERVER_NEW/OLD)`
统一返回结构:
```python
{
@@ -554,16 +721,23 @@ def wait_message_for_external(... ) -> dict: ...
}
```
### 8.6 `wait_message_for_external()`
补充约束:
伪码:
- Graph 详情读取成功时,会额外调用 `graph_service.get_email_raw_graph()` 尝试补 `raw_content`
- IMAP 分支无独立 HTML body 时,`content` 与 `raw_content` 可能来自同一份正文
- message detail 与 raw 接口实际共享同一底层 service,避免双份读取逻辑
### 8.6 `wait_for_message()`
当前真实逻辑不是“只要有最新邮件就返回”,而是“只返回本次请求开始之后出现的匹配邮件”。核心伪码:
```python
baseline_timestamp = int(time.time())
start = time.time()
while time.time() - start < timeout_seconds:
result = get_latest_message_for_external(...)
if result:
return result
latest_message = get_latest_message_for_external(...)
if int(latest_message.get("timestamp") or 0) >= baseline_timestamp:
return latest_message
time.sleep(poll_interval)
raise MailNotFoundError("等待超时,未检测到匹配邮件")
```
@@ -572,31 +746,58 @@ raise MailNotFoundError("等待超时,未检测到匹配邮件")
- 最大超时 120 秒
- 默认 30 秒
- 不使用异步任务,不引入 scheduler
- 若当前轮次没有匹配邮件,内部会捕获 `MailNotFoundError` 继续轮询
- 返回结果是消息摘要,不是完整 message detail
### 8.7 `wait-message` 解耦演进设计(后续阶段)
当前同步轮询实现适用于:
- 本地化部署
- 低并发
- 单可信调用方
但不适用于:
- 单 worker 公网暴露
- 高并发等待场景
- 需要稳定 SLA 的对外 API
后续建议演进为两层架构:
```text
后台轮询器 / worker
↓ 周期性拉取最近邮件
↓ 归一化并写入缓存表 / 状态表
Web API
↓ 查询最近状态
↓ 只负责返回,不负责 sleep 轮询
```
建议增加的抽象:
| 抽象 | 作用 |
|---|---|
| `external_message_cache` | 缓存最近消息摘要或匹配结果 |
| `external_probe_runs` | 记录最近一次探测时间、结果、错误 |
| `wait_message_async()` | 基于缓存或后台结果返回最新状态 |
演进策略建议:
1. 首先在公网模式禁用 `wait-message`
2. 其次引入后台探测状态
3. 最后再决定是否保留 `wait-message` 对外暴露
---
## 9. 邮件读取与回退链路细节
### 9.1 `_read_emails_with_fallback()`
### 9.1 当前真实列表读取链路
建议从 `controllers/emails.py` 现有逻辑下沉到 `services/external_api.py`。
当前没有单独暴露 `_read_emails_with_fallback()` 私有函数,回退逻辑直接写在 `list_messages_for_external()` 中。
函数签名:
```python
def _read_emails_with_fallback(account: dict, folder: str, skip: int, top: int) -> dict:
"""
返回:
{
"success": True,
"emails": [...],
"method": "Graph API"
}
或抛出可识别异常。
"""
```
### 9.2 Graph 优先读取
#### Outlook 账号链路
```python
graph_result = graph_service.get_emails_graph(
@@ -617,7 +818,7 @@ graph_result = graph_service.get_emails_graph(
- 收集 `graph_error`
- 若是 `ProxyError` / `ConnectionError`,直接中止并抛 `ProxyError`
### 9.3 IMAP 回退读取
#### Outlook IMAP 回退链路
按顺序:
@@ -626,23 +827,42 @@ graph_result = graph_service.get_emails_graph(
成功时返回 `method = "IMAP (New)"` 或 `"IMAP (Old)"`
### 9.4 `_read_email_detail_with_fallback()`
#### 通用 IMAP 账号链路
若 `account_type == "imap"`,则不走 Graph / Outlook XOAUTH2 IMAP 回退,而是直接走:
```python
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,
)
```
### 9.2 当前真实详情读取链路
实现顺序:
1. `graph_service.get_email_detail_graph()`
2. `imap_service.get_email_detail_imap(..., folder)`
3. 若都失败,抛 `UpstreamReadFailedError`
1. `account_type == "imap"` 时,走 `get_email_detail_imap_generic()`
2. Outlook 账号时,先 `graph_service.get_email_detail_graph()`
3. Graph 成功后额外尝试 `graph_service.get_email_raw_graph()`
4. Graph 失败后依次回退 `imap_service.get_email_detail_imap_with_server(..., IMAP_SERVER_NEW/OLD)`
5. 三条链路都失败则抛 `MailNotFoundError`
### 9.5 非 Outlook IMAP 兼容说明
### 9.3 非 Outlook IMAP 兼容说明
当前开放 API 第一版目标是 Outlook 邮箱,但根项目已存在 `account_type == "imap"` 分支。
因此建议:
因此当前真实实现已经做到:
- 开放 API 仍允许对 `account_type == "imap"` 的账号调用
- 列表读取时复用 `get_emails_imap_generic()`
- 详情读取时复用 `get_email_detail_imap_generic()`
- `preferred_method` 返回 `imap_generic`
- system `account-status` 中 `preferred_method` 返回 `imap_generic`
这样与 PRD 的“本地化部署邮件读取服务”目标更一致,也不把开放接口写死为仅支持 Outlook OAuth
@@ -684,11 +904,11 @@ def extract_verification_info_with_options(
| `html` | 仅 HTML 内容 |
| `all` | 主题 + 纯文本 + HTML |
伪码:
真实实现要点:
```python
subject = email.get("subject", "")
content = extract_email_text(email)
content = _extract_content_text_without_subject(email)
html_content = email.get("body_html") or email.get("html_content") or ""
if code_source == "subject":
@@ -705,7 +925,7 @@ else:
若传入 `code_regex`:
- 先 `re.compile(code_regex)`
- 编译失败抛 `InvalidParamError("code_regex 参数无效")`
- 编译失败抛 `ValueError("code_regex 参数无效")`
- 编译成功后直接用于优先提取
### 10.5 `code_length` 实现
@@ -717,11 +937,11 @@ else:
```python
m = re.match(r"^(\d+)-(\d+)$", code_length)
if not m:
raise InvalidParamError("code_length 参数无效")
raise ValueError("code_length 参数无效")
min_len = int(m.group(1))
max_len = int(m.group(2))
if min_len > max_len:
raise InvalidParamError("code_length 参数无效")
raise ValueError("code_length 参数无效")
pattern = rf"\b\d{{{min_len},{max_len}}}\b"
```
@@ -744,7 +964,7 @@ DEFAULT_LINK_KEYWORDS = [
2. 遍历关键词
3. 返回第一个包含关键词的链接
4. 若无命中,返回 `links[0]`
5. 若无链接,抛 `VerificationLinkNotFoundError`
5. 若无链接,提取器返回 `verification_link=None`,由 controller 映射为 `VERIFICATION_LINK_NOT_FOUND`
---
@@ -772,9 +992,9 @@ DEFAULT_LINK_KEYWORDS = [
}
```
### 11.3 自定义异常建议
### 11.3 自定义异常真实定义
建议在 `services/external_api.py` 中定义轻量异常:
`services/external_api.py` 当前已定义以下轻量异常:
```python
class ExternalApiError(Exception):
@@ -833,11 +1053,7 @@ class UpstreamReadFailedError(ExternalApiError):
文件:`outlook_web/controllers/system.py`
目标:
- API Key 鉴权后返回轻量服务健康信息
- 不暴露管理员级详细调度器状态
建议实现:
当前真实实现:
```python
@api_key_required
@@ -850,24 +1066,28 @@ def api_external_health():
except Exception:
db_ok = False
return jsonify({
"success": True,
"code": "OK",
"message": "success",
"data": {
"status": "ok" if db_ok else "degraded",
"service": "outlook-email-plus",
"server_time_utc": utcnow().isoformat() + "Z",
"database": "ok" if db_ok else "error",
},
})
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()
```
补充约束:
- 当前 `health` 的语义偏向“服务进程与数据库可用性检查”
- 当前不把它定义为“Graph / IMAP 上游真实可读性证明”
- 即使数据库探测结果是 `error`,只要接口本身未抛异常,当前实现仍返回 HTTP 200,差异体现在 `data.database`
- 若后续进入公网模式,应增加 `public_mode`、`restricted_features`、`upstream_probe_ok` 等字段
### 12.2 `api_external_capabilities()`
返回固定能力列表:
当前真实返回固定能力列表:
```python
FEATURES = [
@@ -880,24 +1100,56 @@ FEATURES = [
]
```
并附带:
- `service`
- `version`
后续增强建议:
```python
{
"service": "outlook-email-plus",
"version": APP_VERSION,
"features": [...],
"public_mode": False,
"restricted_features": [],
}
```
约束:
- 私有模式下 `restricted_features` 可为空
- 公网模式下应显式列出:
- `wait_message`
- `raw_content`
等被禁用或降级的能力
### 12.3 `api_external_account_status()`
实现:
当前真实实现:
1. 校验 `email`
2. 查询 `accounts_repo.get_account_by_email(email)`
3. 若不存在,返回 `ACCOUNT_NOT_FOUND`
4. 存在则返回:
- `email`
- `exists`
- `account_type`
- `provider`
- `group_id`
- `status`
- `last_refresh_at`
- `preferred_method`
- `can_read=True`
- `can_read`
注意:
- 不在该接口中真正拉信
- 该接口是自检接口,不是链路测试接口
- `can_read` 不是固定 `True`,而是按真实账号条件计算:
- `status == disabled` 时为 `False`
- `account_type == "imap"` 时要求 `imap_host` 和 `imap_password` 都存在
- Outlook 账号要求 `client_id` 和 `refresh_token` 都存在
- 后续如需更强自检,应补充 `probe_method`、`probe_ok`、`probe_error`、`last_probe_at`
---
@@ -907,9 +1159,15 @@ FEATURES = [
在系统设置页新增区块:
- `externalApiKey`:密码输入框
- `settingsExternalApiKey`:密码输入框
- `externalApiKeyHint`:显示“已配置/未配置”与脱敏值
- `generateExternalApiKeyBtn`:生成随机 Key
- 保存按钮继续复用现有 `saveSettings()`
当前真实 UI 约束:
- 输入框使用密码类型,避免直接暴露屏幕内容
- 不单独新增新的保存接口,继续走 `/api/settings`
- 当前未单独增加“随机生成对外 API Key”按钮,因此 TDD 不把它列为必做项
### 13.2 `static/js/main.js`
@@ -918,34 +1176,42 @@ FEATURES = [
在现有 settings 拉取成功后追加:
```javascript
const externalApiKeyInput = document.getElementById('externalApiKey');
const externalApiKeyInput = document.getElementById('settingsExternalApiKey');
const externalApiKeyHint = document.getElementById('externalApiKeyHint');
if (externalApiKeyInput) externalApiKeyInput.value = '';
if (externalApiKeyHint) {
if (externalApiKeyInput) {
const masked = data.settings.external_api_key_masked || '';
const isSet = !!data.settings.external_api_key_set;
externalApiKeyHint.textContent = isSet ? `已配置:${masked}` : '未配置';
externalApiKeyInput.value = masked;
externalApiKeyInput.dataset.maskedValue = masked;
externalApiKeyInput.dataset.isSet = data.settings.external_api_key_set ? 'true' : 'false';
}
if (externalApiKeyHint) {
if (data.settings.external_api_key_set) {
externalApiKeyHint.textContent = `已设置:${data.settings.external_api_key_masked || ''}(请求头:X-API-Key;清空后保存可禁用对外接口)`;
} else {
externalApiKeyHint.textContent = '未设置(设置后可通过 /api/external/* 对外开放接口读取邮件与验证码)';
}
}
```
#### 13.2.2 `saveSettings()`
```javascript
const externalApiKey = document.getElementById('externalApiKey')?.value?.trim() || '';
settings.external_api_key = externalApiKey;
```
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;
#### 13.2.3 随机生成按钮
```javascript
function generateExternalApiKey() {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
const value = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
document.getElementById('externalApiKey').value = value;
if (!(externalApiKeyIsSet && externalApiKey && externalApiKey === externalApiKeyMasked)) {
settings.external_api_key = externalApiKey;
}
```
实现注意点:
- 必须避免把脱敏占位符直接写回 DB
- 允许用户通过“清空 + 保存”禁用 external API
- 提示文案要明确告知 Header 使用方式:`X-API-Key`
---
## 14. 兼容性与回归保障
@@ -975,13 +1241,14 @@ function generateExternalApiKey() {
## 15. 测试策略与测试用例
### 15.1 测试文件建议
### 15.1 当前已存在的测试文件
| 文件 | 说明 |
|---|---|
| `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` | 设置页 DOM / 前端脚本接线检查 |
### 15.2 鉴权测试
@@ -989,6 +1256,9 @@ function generateExternalApiKey() {
- 未配置 `external_api_key` → 403 `API_KEY_NOT_CONFIGURED`
- 错误 key → 401 `UNAUTHORIZED`
- 正确 key → 进入 controller
- `public_mode + IP 不在白名单` → 403 `IP_NOT_ALLOWED`(后续阶段)
- `public_mode + wait-message 已禁用` → 403 `FEATURE_DISABLED`(后续阶段)
- `public_mode + 请求频率超限` → 429 `RATE_LIMITED`(后续阶段)
### 15.3 `messages` 接口测试
@@ -998,6 +1268,7 @@ function generateExternalApiKey() {
- Graph 成功时 `method=Graph API`
- Graph 失败 IMAP 成功时返回成功
- Graph 代理错误时返回 502 `PROXY_ERROR`
- `account_type == "imap"` 时走 `get_emails_imap_generic()`
### 15.4 `messages/latest` 测试
@@ -1009,6 +1280,7 @@ function generateExternalApiKey() {
- 正常获取详情
- 缺失 email → 400
- message_id 无效 → 404/502(视底层行为映射)
- `raw` 接口仅返回 `id/email_address/raw_content/method`
### 15.6 `verification-code` 测试
@@ -1030,6 +1302,18 @@ function generateExternalApiKey() {
- 多轮轮询后命中
- 超时返回 404 `MAIL_NOT_FOUND`
- `timeout_seconds > 120` → 400
- `poll_interval > timeout_seconds` → 400
- 仅当消息 `timestamp >= 请求开始时间` 时才返回,旧消息不会误命中
补充说明:
- 当前测试验证的是受控环境中的功能正确性
- 尚未覆盖公网模式下的接口分级、白名单、限流或高并发阻塞场景
- 若后续引入 `public_mode`,需新增:
- 公网模式禁用 `wait-message`
- 公网模式禁用或限制 RAW 接口
- 白名单命中 / 拒绝
- 限流命中返回 429
### 15.9 系统接口测试
@@ -1041,32 +1325,53 @@ function generateExternalApiKey() {
## 16. 实施顺序建议
### 16.1 第一阶段:配置与鉴权
### 16.1 当前已完成的落地顺序
1. `db.py` 初始化 `external_api_key`
2. `repositories/settings.py` 增加读取/脱敏函数
3. `security/auth.py` 增加 `api_key_required`
4. `controllers/settings.py` 与设置页对接
### 16.2 第二阶段:Service 抽离
### 16.2 当前已完成的服务与路由落地
1. 新增 `services/external_api.py`
2. 抽出 `_read_emails_with_fallback()`
3. 抽出 `_read_email_detail_with_fallback()`
4. 增强 `verification_extractor.py`
2. 在 `services/external_api.py` 内落地列表/详情/验证码/等待/审计编排
3. 增强 `verification_extractor.py`
4. 注册 `/api/external/messages*`
5. 注册 `/api/external/verification-*`
6. 注册 `/api/external/health|capabilities|account-status`
### 16.3 第三阶段:开放路由与 controller
1. 注册 `/api/external/messages*`
2. 注册 `/api/external/verification-*`
3. 注册 `/api/external/health|capabilities|account-status`
4. 接入审计日志
### 16.4 第四阶段:测试与文档
### 16.3 当前已完成的验证与文档落地
1. 补充 `tests/test_external_api.py`
2. 对照 OpenAPI 做返回结构校验
3. 更新 README / API 文档示例
2. 补充 `tests/test_settings_external_api_key.py`
3. 补充 `tests/test_verification_extractor_options.py`
4. 补充 `tests/test_ui_settings_external_api_key.py`
5. 同步 PRD / FD / API 文档 / TDD
### 16.4 后续实现重点
1. 对照 OpenAPI 持续校验返回结构与错误码
2. 视上线模式决定是否补充 README 的对外接入示例
3. 评估是否需要把 `wait-message` 从同步轮询迁移为后台探测模型
### 16.5 后续阶段建议(非首版)
1. 增加 `public_mode` 配置项与 `restricted_features`
2. 增加来源 IP 白名单
3. 增加外部接口限流
4. 将 `wait-message` 逐步迁移为后台轮询或异步等待模型
5. 如需多外部调用方,再设计多 API Key 与邮箱范围授权
### 16.6 参考项目经验沉淀
本项目后续演进建议参考但不照搬以下方向:
| 参考项目 | 可借鉴点 | 不照搬点 |
|---|---|---|
| `exeample/outlookEmail` | 最小外部 API 闭环、独立 API 文档 | query 参数传 key、明文 API Key |
| `exeample/BillionMail` | 白名单 / Fail2Ban 这类外围防护思路 | 其整体邮件系统架构与本项目不同 |
| `exeample/MailAggregator_Pro` | 后台轮询与 Web 接口解耦、健康状态动态暴露 | 其异步框架与当前 Flask 架构不同 |
---
@@ -1077,4 +1382,5 @@ function generateExternalApiKey() {
- **把开放 API 做成一层薄而稳定的外壳**,底层仍复用现有内部邮件读取能力;
- **把验证码/链接提取从“页面能力”升级为“服务能力”**,并支持参数化配置;
- **把设置、鉴权、审计、健康检查一起落地**,让开放接口从第一版就具备可配置、可接入、可排查的完整闭环;
- **通过新增 `services/external_api.py` 控制复杂度**,避免继续把复杂逻辑塞回 controller。
- **通过新增 `services/external_api.py` 控制复杂度**,避免继续把复杂逻辑塞回 controller;
- **后续继续沿“鉴权收敛 + 轮询解耦”两条主线演进**,而不是直接把当前实现定义为公网开放平台。
@@ -12,6 +12,7 @@
- `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`
---
@@ -95,6 +96,7 @@
3. **隔离外部网络依赖**:Graph、IMAP、时间等待统一通过 `mock` 处理,不依赖真实网络。
4. **返回结构与 OpenAPI 对齐**:测试中必须校验 `success/code/message/data` 四元结构。
5. **重点关注错误路径**:鉴权失败、账号不存在、无匹配邮件、无验证码、无链接、上游失败都要覆盖。
6. **默认参数语义与 PRD 对齐**:`verification-*` 默认最近 10 分钟;`messages` / `wait-message` 不应被测试为默认 10 分钟窗口。
### 2.2 Mock 策略
+19 -2
View File
@@ -64,8 +64,8 @@ P0 阶段不建议再新增以下业务接口:
| 阶段 | 类型 | 是否已定义为独立接口 | 说明 |
|------|------|----------------------|------|
| P1 | 公网模式、IP 白名单、限流 | 否 | 更适合加在鉴权层和安全层 |
| P1 | 动态上游探测 | 否 | 优先增强现有 `/health`、`/account-status` 返回字段 |
| P1 | 公网模式、IP 白名单、限流 | 已实现(v1.1) | 安全守卫层 `external_api_guard.py` |
| P1 | 高风险接口禁用(raw/wait-message) | 已实现(v1.1) | 设置页可动态开关 |
| P2 | `wait-message` 解耦 | 否 | 先做后台轮询与缓存,再决定是否新增异步接口 |
| P2 | 多 API Key / 范围授权 | 否 | 优先落在设置与鉴权模型,不急于新增公开路径 |
@@ -104,6 +104,20 @@ X-API-Key: your-api-key
- 不支持 query 参数中的 `api_key`
- 未配置 `external_api_key` 时,统一返回 `403 API_KEY_NOT_CONFIGURED`
### 2.2.1 P1 公网安全层(v1.1 新增)
在 API Key 鉴权之后,额外叠加以下安全控制。**仅在"公网模式"开启时生效**,默认关闭(与 P0 行为完全一致)。
| 配置项 | 键名 | 默认值 | 说明 |
|--------|------|--------|------|
| 公网模式 | `external_api_public_mode` | `false` | 开启后激活 IP 白名单、限流、功能禁用 |
| IP 白名单 | `external_api_ip_whitelist` | `[]` | JSON 数组,支持精确 IP 和 CIDR(如 `192.168.0.0/16`);为空则不限制 |
| 限流阈值 | `external_api_rate_limit_per_minute` | `60` | 每 IP 每分钟最大请求数 |
| 禁用 raw | `external_api_disable_raw_content` | `false` | 禁止 `/messages/{id}/raw` 端点 |
| 禁用 wait-message | `external_api_disable_wait_message` | `false` | 禁止 `/wait-message` 端点 |
在设置页 → 对外开放 API → 🛡️ 公网安全配置 中可动态修改,无需重启。
### 2.3 通用成功响应
```json
@@ -140,6 +154,9 @@ X-API-Key: your-api-key
| `PROXY_ERROR` | `502` | 代理连接失败 |
| `UPSTREAM_READ_FAILED` | `502` | Graph / IMAP 均读取失败 |
| `INTERNAL_ERROR` | `500` | 服务内部错误 |
| `IP_NOT_ALLOWED` | `403` | 当前 IP 不在白名单中(公网模式) |
| `FEATURE_DISABLED` | `403` | 功能在公网模式下已禁用 |
| `RATE_LIMIT_EXCEEDED` | `429` | 请求频率超限(公网模式) |
### 2.6 通用查询参数
+8
View File
@@ -12,6 +12,7 @@ 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 api_key_required, login_required
from outlook_web.security.external_api_guard import external_api_guards
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
@@ -633,6 +634,7 @@ def _external_error_response(exc: external_api_service.ExternalApiError):
@api_key_required
@external_api_guards()
def api_external_get_messages() -> Any:
try:
args = _parse_external_common_args()
@@ -679,6 +681,7 @@ def api_external_get_messages() -> Any:
@api_key_required
@external_api_guards()
def api_external_get_latest_message() -> Any:
try:
args = _parse_external_common_args()
@@ -718,6 +721,7 @@ def api_external_get_latest_message() -> Any:
@api_key_required
@external_api_guards()
def api_external_get_message_detail(message_id: str) -> Any:
try:
args = _parse_external_common_args()
@@ -755,6 +759,7 @@ def api_external_get_message_detail(message_id: str) -> Any:
@api_key_required
@external_api_guards(feature="raw_content")
def api_external_get_message_raw(message_id: str) -> Any:
try:
args = _parse_external_common_args()
@@ -801,6 +806,7 @@ def api_external_get_message_raw(message_id: str) -> Any:
@api_key_required
@external_api_guards()
def api_external_get_verification_code() -> Any:
try:
args = _parse_external_common_args(default_since_minutes=10)
@@ -861,6 +867,7 @@ def api_external_get_verification_code() -> Any:
@api_key_required
@external_api_guards()
def api_external_get_verification_link() -> Any:
try:
args = _parse_external_common_args(default_since_minutes=10)
@@ -903,6 +910,7 @@ def api_external_get_verification_link() -> Any:
@api_key_required
@external_api_guards(feature="wait_message")
def api_external_wait_message() -> Any:
try:
args = _parse_external_common_args()
+69
View File
@@ -51,6 +51,13 @@ def api_get_settings() -> Any:
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 ""
# P1:公网模式安全配置
safe_settings["external_api_public_mode"] = settings_repo.get_external_api_public_mode()
safe_settings["external_api_ip_whitelist"] = settings_repo.get_external_api_ip_whitelist()
safe_settings["external_api_rate_limit_per_minute"] = settings_repo.get_external_api_rate_limit()
safe_settings["external_api_disable_raw_content"] = settings_repo.get_external_api_disable_raw_content()
safe_settings["external_api_disable_wait_message"] = settings_repo.get_external_api_disable_wait_message()
# Telegram 推送配置
tg_bot_token_raw = all_settings.get("telegram_bot_token", "")
if tg_bot_token_raw and is_encrypted(tg_bot_token_raw):
@@ -141,6 +148,68 @@ def api_update_settings() -> Any:
else:
errors.append("清空对外 API Key 失败")
# P1:公网模式安全配置
if "external_api_public_mode" in data:
val = str(data["external_api_public_mode"]).lower()
if val in ("true", "false"):
if settings_repo.set_setting("external_api_public_mode", val):
updated.append("对外 API 公网模式")
else:
errors.append("更新公网模式失败")
else:
errors.append("公网模式必须是 true 或 false")
if "external_api_ip_whitelist" in data:
raw = data["external_api_ip_whitelist"]
if isinstance(raw, list):
whitelist_str = json.dumps(raw, ensure_ascii=False)
else:
whitelist_str = str(raw).strip()
# 简单校验 JSON 数组格式
try:
parsed = json.loads(whitelist_str)
if not isinstance(parsed, list):
errors.append("IP 白名单必须是 JSON 数组格式")
else:
if settings_repo.set_setting("external_api_ip_whitelist", whitelist_str):
updated.append("对外 API IP 白名单")
else:
errors.append("更新 IP 白名单失败")
except (json.JSONDecodeError, TypeError):
errors.append("IP 白名单格式无效(应为 JSON 数组)")
if "external_api_rate_limit_per_minute" in data:
try:
limit = int(data["external_api_rate_limit_per_minute"])
if limit < 1 or limit > 10000:
errors.append("限流阈值必须在 1-10000 之间")
elif settings_repo.set_setting("external_api_rate_limit_per_minute", str(limit)):
updated.append("对外 API 限流阈值")
else:
errors.append("更新限流阈值失败")
except (ValueError, TypeError):
errors.append("限流阈值必须是数字")
if "external_api_disable_raw_content" in data:
val = str(data["external_api_disable_raw_content"]).lower()
if val in ("true", "false"):
if settings_repo.set_setting("external_api_disable_raw_content", val):
updated.append("对外 API 禁用 raw 端点")
else:
errors.append("更新禁用 raw 端点失败")
else:
errors.append("禁用 raw 端点必须是 true 或 false")
if "external_api_disable_wait_message" in data:
val = str(data["external_api_disable_wait_message"]).lower()
if val in ("true", "false"):
if settings_repo.set_setting("external_api_disable_wait_message", val):
updated.append("对外 API 禁用 wait-message 端点")
else:
errors.append("更新禁用 wait-message 端点失败")
else:
errors.append("禁用 wait-message 端点必须是 true 或 false")
# 更新刷新周期
if "refresh_interval_days" in data:
try:
+23 -8
View File
@@ -18,6 +18,7 @@ from outlook_web.db import (
from outlook_web.repositories import accounts as accounts_repo
from outlook_web.repositories import settings as settings_repo
from outlook_web.security.auth import api_key_required, login_required
from outlook_web.security.external_api_guard import external_api_guards
from outlook_web.services import external_api as external_api_service
# 常量
@@ -255,6 +256,7 @@ def api_system_upgrade_status() -> Any:
@api_key_required
@external_api_guards()
def api_external_health() -> Any:
"""对外健康检查(不依赖登录态)"""
conn = create_sqlite_connection()
@@ -294,19 +296,31 @@ def api_external_health() -> Any:
@api_key_required
@external_api_guards()
def api_external_capabilities() -> Any:
"""对外能力说明接口"""
public_mode = settings_repo.get_external_api_public_mode()
restricted = []
all_features = [
"message_list",
"message_detail",
"raw_content",
"verification_code",
"verification_link",
"wait_message",
]
if public_mode:
if settings_repo.get_external_api_disable_raw_content():
restricted.append("raw_content")
if settings_repo.get_external_api_disable_wait_message():
restricted.append("wait_message")
available = [f for f in all_features if f not in restricted]
data = {
"service": "outlook-email-plus",
"version": APP_VERSION,
"features": [
"message_list",
"message_detail",
"raw_content",
"verification_code",
"verification_link",
"wait_message",
],
"public_mode": public_mode,
"features": available,
"restricted_features": restricted,
}
external_api_service.audit_external_api_access(
action="external_api_access",
@@ -319,6 +333,7 @@ def api_external_capabilities() -> Any:
@api_key_required
@external_api_guards()
def api_external_account_status() -> Any:
"""对外账号状态检查"""
email_addr = (request.args.get("email") or "").strip()
+30 -1
View File
@@ -19,7 +19,8 @@ from outlook_web.security.crypto import (
# 数据库 Schema 版本(用于升级可验证/可诊断)
# v3:对齐 PRD-00005 / FD-00005 / TDD-00005(accounts 表新增多邮箱字段:account_type/provider/imap_host/imap_port/imap_password)
# v5:BUG-00011 P2 — Message-ID 去重防止重复推送
DB_SCHEMA_VERSION = 5
# v6:PRD-00008 P1 — 对外 API 限流表 + 公网模式默认配置
DB_SCHEMA_VERSION = 6
DB_SCHEMA_VERSION_KEY = "db_schema_version"
DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY = "db_schema_last_upgrade_trace_id"
DB_SCHEMA_LAST_UPGRADE_ERROR_KEY = "db_schema_last_upgrade_error"
@@ -509,6 +510,34 @@ def init_db(database_path: Optional[str] = None):
ON telegram_push_log(pushed_at)
""")
# v6: 对外 API 限流表 + 公网模式默认配置(PRD-00008 P1)
cursor.execute("""
CREATE TABLE IF NOT EXISTS external_api_rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
minute_bucket INTEGER NOT NULL,
request_count INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(ip_address, minute_bucket)
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_ext_rate_ip_bucket
ON external_api_rate_limits(ip_address, minute_bucket)
""")
# P1 默认配置
for key, default in [
("external_api_public_mode", "false"),
("external_api_ip_whitelist", "[]"),
("external_api_rate_limit_per_minute", "60"),
("external_api_disable_wait_message", "false"),
("external_api_disable_raw_content", "false"),
]:
cursor.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
(key, default),
)
# 迁移现有明文数据为加密数据
migrate_sensitive_data(conn)
+39
View File
@@ -79,3 +79,42 @@ def get_external_api_key_masked(head: int = 4, tail: int = 4) -> str:
if len(safe_value) <= head + tail:
return "*" * len(safe_value)
return safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:]
# ── P1:公网模式安全配置 ──────────────────────────────
def get_external_api_public_mode() -> bool:
"""公网模式是否开启(默认关闭,保持 P0 受控私有行为)。"""
return get_setting("external_api_public_mode", "false").lower() == "true"
def get_external_api_ip_whitelist() -> list:
"""IP 白名单列表(JSON 数组,支持 CIDR 如 '192.168.1.0/24')。"""
import json
raw = get_setting("external_api_ip_whitelist", "[]")
try:
result = json.loads(raw)
return result if isinstance(result, list) else []
except (json.JSONDecodeError, TypeError):
return []
def get_external_api_rate_limit() -> int:
"""每分钟每 IP 最大请求数(默认 60)。"""
try:
val = int(get_setting("external_api_rate_limit_per_minute", "60"))
return max(1, val)
except (ValueError, TypeError):
return 60
def get_external_api_disable_wait_message() -> bool:
"""是否禁用 wait-message 端点(默认不禁用)。"""
return get_setting("external_api_disable_wait_message", "false").lower() == "true"
def get_external_api_disable_raw_content() -> bool:
"""是否禁用 raw 端点(默认不禁用)。"""
return get_setting("external_api_disable_raw_content", "false").lower() == "true"
+196
View File
@@ -0,0 +1,196 @@
"""
P1 对外 API 安全守卫 — 公网模式控制层
职责:IP 白名单校验、高风险接口禁用、基础限流。
位于 api_key_required 之后、controller 逻辑之前。
错误码:
- IP_NOT_ALLOWED (403)
- FEATURE_DISABLED (403)
- RATE_LIMIT_EXCEEDED (429)
"""
from __future__ import annotations
import ipaddress
import time
from functools import wraps
from typing import Any, Callable, Optional
from flask import jsonify, request
from outlook_web.db import get_db
from outlook_web.repositories import settings as settings_repo
from outlook_web.services import external_api as external_api_service
# ── IP 白名单 ────────────────────────────────────────
def _get_client_ip() -> str:
"""获取客户端真实 IP(优先 X-Forwarded-For)。"""
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.remote_addr or "127.0.0.1"
def _ip_in_whitelist(ip: str, whitelist: list) -> bool:
"""检查 IP 是否在白名单内(支持 CIDR 和精确匹配)。"""
if not whitelist:
return True
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
for entry in whitelist:
try:
if "/" in str(entry):
if addr in ipaddress.ip_network(entry, strict=False):
return True
else:
if addr == ipaddress.ip_address(entry):
return True
except (ValueError, TypeError):
continue
return False
def check_ip_whitelist() -> Optional[Any]:
"""
检查 IP 白名单。仅在公网模式开启且白名单非空时生效。
返回 None 表示通过,返回 Response 表示拒绝。
"""
if not settings_repo.get_external_api_public_mode():
return None
whitelist = settings_repo.get_external_api_ip_whitelist()
if not whitelist:
return None
client_ip = _get_client_ip()
if _ip_in_whitelist(client_ip, whitelist):
return None
return jsonify(external_api_service.fail(
code="IP_NOT_ALLOWED",
message="当前 IP 不在白名单中",
data={"ip": client_ip},
)), 403
# ── 高风险接口禁用 ────────────────────────────────────
def check_feature_enabled(feature: str) -> Optional[Any]:
"""
检查功能是否被禁用。仅在公网模式开启时生效。
feature: 'wait_message' | 'raw_content'
返回 None 表示通过,返回 Response 表示拒绝。
"""
if not settings_repo.get_external_api_public_mode():
return None
disabled = False
if feature == "wait_message":
disabled = settings_repo.get_external_api_disable_wait_message()
elif feature == "raw_content":
disabled = settings_repo.get_external_api_disable_raw_content()
if not disabled:
return None
return jsonify(external_api_service.fail(
code="FEATURE_DISABLED",
message=f"功能 {feature} 在公网模式下已禁用",
data={"feature": feature},
)), 403
# ── 基础限流(滑动窗口,按 IP + 分钟桶) ─────────────
def _current_minute_bucket() -> int:
return int(time.time()) // 60
def _cleanup_old_buckets(db: Any) -> None:
"""清理 5 分钟前的限流记录。"""
cutoff = _current_minute_bucket() - 5
try:
db.execute(
"DELETE FROM external_api_rate_limits WHERE minute_bucket < ?",
(cutoff,),
)
except Exception:
pass
def check_rate_limit() -> Optional[Any]:
"""
检查限流。仅在公网模式开启时生效。
返回 None 表示通过,返回 Response 表示拒绝。
"""
if not settings_repo.get_external_api_public_mode():
return None
limit = settings_repo.get_external_api_rate_limit()
client_ip = _get_client_ip()
bucket = _current_minute_bucket()
try:
db = get_db()
_cleanup_old_buckets(db)
# 原子递增
db.execute(
"""
INSERT INTO external_api_rate_limits (ip_address, minute_bucket, request_count)
VALUES (?, ?, 1)
ON CONFLICT(ip_address, minute_bucket)
DO UPDATE SET request_count = request_count + 1
""",
(client_ip, bucket),
)
db.commit()
row = db.execute(
"SELECT request_count FROM external_api_rate_limits WHERE ip_address = ? AND minute_bucket = ?",
(client_ip, bucket),
).fetchone()
count = row["request_count"] if row else 0
except Exception:
return None
if count > limit:
return jsonify(external_api_service.fail(
code="RATE_LIMIT_EXCEEDED",
message=f"请求频率超限({limit} 次/分钟)",
data={"limit": limit, "current": count, "ip": client_ip},
)), 429
return None
# ── 组合守卫装饰器 ────────────────────────────────────
def external_api_guards(feature: Optional[str] = None) -> Callable:
"""
组合守卫装饰器:IP 白名单 → 限流 → 功能禁用检查。
放在 @api_key_required 之后。
用法:
@api_key_required
@external_api_guards(feature="raw_content")
def api_external_raw(message_id):
...
"""
def decorator(f: Callable) -> Callable:
@wraps(f)
def decorated(*args: Any, **kwargs: Any) -> Any:
# 1. IP 白名单
resp = check_ip_whitelist()
if resp is not None:
return resp
# 2. 限流
resp = check_rate_limit()
if resp is not None:
return resp
# 3. 功能禁用
if feature:
resp = check_feature_enabled(feature)
if resp is not None:
return resp
return f(*args, **kwargs)
return decorated
return decorator
+1
View File
@@ -527,6 +527,7 @@ def wait_for_message(
if poll_interval <= 0 or poll_interval > timeout_seconds:
raise InvalidParamError("poll_interval 参数无效")
# 记录进入等待接口时的时间戳,避免把请求开始前已存在的旧邮件误判成“新到达”。
baseline_timestamp = int(time.time())
start = time.time()
last_error: Optional[ExternalApiError] = None
+41
View File
@@ -1003,6 +1003,25 @@ ${details}
}
}
// P1:公网安全配置
const publicModeEl = document.getElementById('externalApiPublicMode');
if (publicModeEl) publicModeEl.checked = data.settings.external_api_public_mode === true;
const ipWhitelistEl = document.getElementById('externalApiIpWhitelist');
if (ipWhitelistEl) {
const wl = data.settings.external_api_ip_whitelist;
ipWhitelistEl.value = Array.isArray(wl) ? wl.join('\n') : '';
}
const rateLimitEl = document.getElementById('externalApiRateLimit');
if (rateLimitEl) rateLimitEl.value = data.settings.external_api_rate_limit_per_minute || 60;
const disableRawEl = document.getElementById('externalApiDisableRaw');
if (disableRawEl) disableRawEl.checked = data.settings.external_api_disable_raw_content === true;
const disableWaitEl = document.getElementById('externalApiDisableWait');
if (disableWaitEl) disableWaitEl.checked = data.settings.external_api_disable_wait_message === true;
// 加载刷新配置
document.getElementById('refreshIntervalDays').value = data.settings.refresh_interval_days || '30';
document.getElementById('refreshDelaySeconds').value = data.settings.refresh_delay_seconds || '5';
@@ -1135,6 +1154,28 @@ ${details}
settings.external_api_key = externalApiKey;
}
// P1:公网安全配置
const publicModeEl = document.getElementById('externalApiPublicMode');
if (publicModeEl) settings.external_api_public_mode = publicModeEl.checked;
const ipWhitelistEl = document.getElementById('externalApiIpWhitelist');
if (ipWhitelistEl) {
const lines = ipWhitelistEl.value.trim().split('\n').map(l => l.trim()).filter(l => l);
settings.external_api_ip_whitelist = lines;
}
const rateLimitEl = document.getElementById('externalApiRateLimit');
if (rateLimitEl) {
const rl = parseInt(rateLimitEl.value);
if (!isNaN(rl)) settings.external_api_rate_limit_per_minute = rl;
}
const disableRawEl = document.getElementById('externalApiDisableRaw');
if (disableRawEl) settings.external_api_disable_raw_content = disableRawEl.checked;
const disableWaitEl = document.getElementById('externalApiDisableWait');
if (disableWaitEl) settings.external_api_disable_wait_message = disableWaitEl.checked;
// 刷新配置
const days = parseInt(refreshDays);
const delay = parseInt(refreshDelay);
+42 -6
View File
@@ -328,12 +328,48 @@
<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 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>
<!-- P1:对外 API 公网安全配置 -->
<div id="externalApiAdvancedSettings" style="margin-top: 0.75rem; padding: 1rem; background: var(--bg-secondary); border-radius: 8px; border: 1px solid var(--border-light);">
<h5 style="font-size: 0.85rem; font-weight: 600; margin-bottom: 0.75rem; color: var(--text-muted);">🛡️ 公网安全配置(P1)</h5>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="externalApiPublicMode">
<span class="form-label" style="margin: 0;">启用公网模式</span>
</label>
<div class="form-hint">关闭时(默认)仅做 API Key 鉴权;开启后额外启用 IP 白名单、限流、高风险端点禁用等安全策略。</div>
</div>
<div class="form-group">
<label class="form-label">IP 白名单 <span style="font-size: 0.75rem; color: var(--text-muted); font-weight: 400;">(仅公网模式生效,留空则不限制)</span></label>
<textarea class="form-input" id="externalApiIpWhitelist" rows="2" placeholder='每行一个 IP 或 CIDR,如 192.168.1.0/24' style="font-family: monospace; font-size: 0.85rem;"></textarea>
<div class="form-hint">支持精确 IP 和 CIDR 格式。每行一个,保存时自动转为 JSON 数组。</div>
</div>
<div class="form-group">
<label class="form-label">每分钟每 IP 限流 <span style="font-size: 0.75rem; color: var(--text-muted); font-weight: 400;">(仅公网模式生效)</span></label>
<input type="number" class="form-input" id="externalApiRateLimit" min="1" max="10000" value="60" style="width: 120px;">
<div class="form-hint">每个 IP 每分钟最大请求数(默认 60)。</div>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="externalApiDisableRaw">
<span class="form-label" style="margin: 0;">禁用 raw(原始内容)端点</span>
</label>
<div class="form-hint">公网模式下建议禁用,防止泄露完整邮件原文。</div>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="externalApiDisableWait">
<span class="form-label" style="margin: 0;">禁用 wait-message(等待新邮件)端点</span>
</label>
<div class="form-hint">公网模式下建议禁用,防止长连接资源耗尽(Slowloris 风险)。</div>
</div>
</div>
<!-- Token 刷新设置 -->
+326
View File
@@ -1124,3 +1124,329 @@ class ExternalApiAuditTests(ExternalApiBaseTest):
if __name__ == "__main__":
unittest.main()
# ══════════════════════════════════════════════════════════════════════
# P1 安全守卫测试
# ══════════════════════════════════════════════════════════════════════
class ExternalApiGuardBaseTest(ExternalApiBaseTest):
"""P1 守卫测试基类:提供公网模式配置 helper。"""
def setUp(self):
super().setUp()
# 确保默认关闭公网模式
self._set_public_mode(False)
self._set_ip_whitelist([])
self._set_rate_limit(60)
self._set_disable_feature("raw_content", False)
self._set_disable_feature("wait_message", False)
# ── helper ──
def _set_public_mode(self, enabled: bool):
with self.app.app_context():
from outlook_web.repositories import settings as settings_repo
settings_repo.set_setting("external_api_public_mode", "true" if enabled else "false")
def _set_ip_whitelist(self, ips: list):
import json as _json
with self.app.app_context():
from outlook_web.repositories import settings as settings_repo
settings_repo.set_setting("external_api_ip_whitelist", _json.dumps(ips))
def _set_rate_limit(self, limit: int):
with self.app.app_context():
from outlook_web.repositories import settings as settings_repo
settings_repo.set_setting("external_api_rate_limit_per_minute", str(limit))
def _set_disable_feature(self, feature: str, disabled: bool):
with self.app.app_context():
from outlook_web.repositories import settings as settings_repo
settings_repo.set_setting(f"external_api_disable_{feature}", "true" if disabled else "false")
def _clear_rate_limits(self):
with self.app.app_context():
from outlook_web.db import get_db
db = get_db()
db.execute("DELETE FROM external_api_rate_limits")
db.commit()
class GuardPublicModeOffTests(ExternalApiGuardBaseTest):
"""TC-GUARD-01~03: public_mode=false 时守卫完全透传。"""
def test_guard_noop_when_private(self):
"""TC-GUARD-01: 私有模式下守卫不生效,请求正常通过"""
self._set_external_api_key("abc123")
self._set_public_mode(False)
self._set_ip_whitelist(["10.0.0.1"]) # 故意设白名单,但私有模式不应检查
client = self.app.test_client()
resp = client.get("/api/external/health", headers=self._auth_headers())
self.assertEqual(resp.status_code, 200)
def test_rate_limit_noop_when_private(self):
"""TC-GUARD-02: 私有模式下限流不生效"""
self._set_external_api_key("abc123")
self._set_public_mode(False)
self._set_rate_limit(1)
client = self.app.test_client()
for _ in range(5):
resp = client.get("/api/external/health", headers=self._auth_headers())
self.assertEqual(resp.status_code, 200)
def test_feature_disable_noop_when_private(self):
"""TC-GUARD-03: 私有模式下功能禁用不生效"""
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
self._set_public_mode(False)
self._set_disable_feature("raw_content", True)
self._set_disable_feature("wait_message", True)
client = self.app.test_client()
# raw 端点 → 应正常进入 controller(可能 404 找不到邮件,但不是 403)
resp = client.get(
f"/api/external/messages/fake-id/raw",
headers=self._auth_headers(),
)
self.assertNotEqual(resp.status_code, 403)
class GuardIpWhitelistTests(ExternalApiGuardBaseTest):
"""TC-GUARD-04~07: IP 白名单功能。"""
def test_ip_rejected_when_not_in_whitelist(self):
"""TC-GUARD-04: 公网模式 + IP 不在白名单 → 403 IP_NOT_ALLOWED"""
self._set_external_api_key("abc123")
self._set_public_mode(True)
self._set_ip_whitelist(["10.0.0.1"])
client = self.app.test_client()
resp = client.get("/api/external/health", headers=self._auth_headers())
self.assertEqual(resp.status_code, 403)
data = resp.get_json()
self.assertEqual(data["code"], "IP_NOT_ALLOWED")
def test_ip_allowed_when_in_whitelist(self):
"""TC-GUARD-05: 公网模式 + IP 在白名单 → 正常通过"""
self._set_external_api_key("abc123")
self._set_public_mode(True)
self._set_ip_whitelist(["127.0.0.1"])
client = self.app.test_client()
resp = client.get("/api/external/health", headers=self._auth_headers())
self.assertEqual(resp.status_code, 200)
def test_empty_whitelist_allows_all(self):
"""TC-GUARD-06: 公网模式 + 白名单为空 → 不限制"""
self._set_external_api_key("abc123")
self._set_public_mode(True)
self._set_ip_whitelist([])
client = self.app.test_client()
resp = client.get("/api/external/health", headers=self._auth_headers())
self.assertEqual(resp.status_code, 200)
def test_cidr_whitelist(self):
"""TC-GUARD-07: CIDR 白名单匹配"""
self._set_external_api_key("abc123")
self._set_public_mode(True)
self._set_ip_whitelist(["127.0.0.0/8"])
client = self.app.test_client()
resp = client.get("/api/external/health", headers=self._auth_headers())
self.assertEqual(resp.status_code, 200)
class GuardFeatureDisableTests(ExternalApiGuardBaseTest):
"""TC-GUARD-08~11: 高风险接口禁用。"""
def test_raw_disabled(self):
"""TC-GUARD-08: 公网模式 + raw 禁用 → 403 FEATURE_DISABLED"""
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
self._set_public_mode(True)
self._set_ip_whitelist(["127.0.0.1"])
self._set_disable_feature("raw_content", True)
client = self.app.test_client()
resp = client.get(
f"/api/external/messages/fake-id/raw",
headers=self._auth_headers(),
)
self.assertEqual(resp.status_code, 403)
data = resp.get_json()
self.assertEqual(data["code"], "FEATURE_DISABLED")
self.assertIn("raw_content", data.get("data", {}).get("feature", ""))
def test_wait_message_disabled(self):
"""TC-GUARD-09: 公网模式 + wait-message 禁用 → 403"""
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
self._set_public_mode(True)
self._set_ip_whitelist(["127.0.0.1"])
self._set_disable_feature("wait_message", True)
client = self.app.test_client()
resp = client.get(
f"/api/external/wait-message?email={email_addr}",
headers=self._auth_headers(),
)
self.assertEqual(resp.status_code, 403)
data = resp.get_json()
self.assertEqual(data["code"], "FEATURE_DISABLED")
def test_raw_allowed_when_not_disabled(self):
"""TC-GUARD-10: 公网模式 + raw 未禁用 → 正常进入 controller"""
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
self._set_public_mode(True)
self._set_ip_whitelist(["127.0.0.1"])
self._set_disable_feature("raw_content", False)
client = self.app.test_client()
resp = client.get(
f"/api/external/messages/fake-id/raw",
headers=self._auth_headers(),
)
# 不是 403 FEATURE_DISABLED(可能是 404/500 等取决于后续逻辑)
self.assertNotEqual(resp.status_code, 403)
def test_wait_message_allowed_when_not_disabled(self):
"""TC-GUARD-11: 公网模式 + wait-message 未禁用 → 正常进入"""
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
self._set_public_mode(True)
self._set_ip_whitelist(["127.0.0.1"])
self._set_disable_feature("wait_message", False)
client = self.app.test_client()
resp = client.get(
f"/api/external/wait-message?email={email_addr}",
headers=self._auth_headers(),
)
self.assertNotEqual(resp.status_code, 403)
class GuardRateLimitTests(ExternalApiGuardBaseTest):
"""TC-GUARD-12~14: 限流功能。"""
def test_rate_limit_exceeded(self):
"""TC-GUARD-12: 公网模式 + 超限 → 429 RATE_LIMIT_EXCEEDED"""
self._set_external_api_key("abc123")
self._set_public_mode(True)
self._set_ip_whitelist(["127.0.0.1"])
self._set_rate_limit(3)
self._clear_rate_limits()
client = self.app.test_client()
results = []
for _ in range(5):
resp = client.get("/api/external/health", headers=self._auth_headers())
results.append(resp.status_code)
# 前 3 次应该通过(200),之后应该是 429
self.assertTrue(any(s == 429 for s in results), f"预期至少一个 429,实际: {results}")
# 检查 429 响应内容
last_429 = [r for r in range(5) if results[r] == 429]
if last_429:
resp = client.get("/api/external/health", headers=self._auth_headers())
if resp.status_code == 429:
data = resp.get_json()
self.assertEqual(data["code"], "RATE_LIMIT_EXCEEDED")
def test_rate_limit_not_exceeded(self):
"""TC-GUARD-13: 公网模式 + 未超限 → 正常通过"""
self._set_external_api_key("abc123")
self._set_public_mode(True)
self._set_ip_whitelist(["127.0.0.1"])
self._set_rate_limit(100)
self._clear_rate_limits()
client = self.app.test_client()
for _ in range(5):
resp = client.get("/api/external/health", headers=self._auth_headers())
self.assertEqual(resp.status_code, 200)
def test_rate_limit_response_structure(self):
"""TC-GUARD-14: 429 响应包含 limit/current/ip"""
self._set_external_api_key("abc123")
self._set_public_mode(True)
self._set_ip_whitelist(["127.0.0.1"])
self._set_rate_limit(1)
self._clear_rate_limits()
client = self.app.test_client()
client.get("/api/external/health", headers=self._auth_headers())
resp = client.get("/api/external/health", headers=self._auth_headers())
if resp.status_code == 429:
data = resp.get_json()
err_data = data.get("data", {})
self.assertIn("limit", err_data)
self.assertIn("current", err_data)
self.assertIn("ip", err_data)
class GuardCapabilitiesTests(ExternalApiGuardBaseTest):
"""TC-GUARD-15~16: capabilities 端点 P1 增强。"""
def test_capabilities_private_mode(self):
"""TC-GUARD-15: 私有模式 capabilities 不含 restricted_features"""
self._set_external_api_key("abc123")
self._set_public_mode(False)
client = self.app.test_client()
resp = client.get("/api/external/capabilities", headers=self._auth_headers())
self.assertEqual(resp.status_code, 200)
data = resp.get_json()["data"]
self.assertFalse(data.get("public_mode", False))
def test_capabilities_public_mode_with_disabled(self):
"""TC-GUARD-16: 公网模式 + 功能禁用 → restricted_features 列出禁用项"""
self._set_external_api_key("abc123")
self._set_public_mode(True)
self._set_ip_whitelist(["127.0.0.1"])
self._set_disable_feature("raw_content", True)
self._set_disable_feature("wait_message", True)
client = self.app.test_client()
resp = client.get("/api/external/capabilities", headers=self._auth_headers())
self.assertEqual(resp.status_code, 200)
data = resp.get_json()["data"]
self.assertTrue(data.get("public_mode"))
restricted = data.get("restricted_features", [])
self.assertIn("raw_content", restricted)
self.assertIn("wait_message", restricted)
class GuardSettingsApiTests(ExternalApiGuardBaseTest):
"""TC-GUARD-17~18: P1 设置读写。"""
def test_get_settings_contains_p1_fields(self):
"""TC-GUARD-17: GET /api/settings 包含 P1 字段"""
with self.app.test_client() as client:
self._login(client)
resp = client.get("/api/settings")
self.assertEqual(resp.status_code, 200)
s = resp.get_json()["settings"]
self.assertIn("external_api_public_mode", s)
self.assertIn("external_api_ip_whitelist", s)
self.assertIn("external_api_rate_limit_per_minute", s)
self.assertIn("external_api_disable_raw_content", s)
self.assertIn("external_api_disable_wait_message", s)
def test_update_p1_settings(self):
"""TC-GUARD-18: PUT /api/settings 可更新 P1 字段"""
with self.app.test_client() as client:
self._login(client)
# 获取 CSRF token
csrf_resp = client.get("/api/csrf-token")
csrf_token = csrf_resp.get_json().get("csrf_token", "")
resp = client.put(
"/api/settings",
json={
"external_api_public_mode": True,
"external_api_ip_whitelist": ["10.0.0.1", "192.168.0.0/16"],
"external_api_rate_limit_per_minute": 30,
"external_api_disable_raw_content": True,
"external_api_disable_wait_message": True,
},
headers={"X-CSRFToken": csrf_token},
)
self.assertEqual(resp.status_code, 200)
data = resp.get_json()
self.assertTrue(data["success"])
# 验证设置已保存
resp2 = client.get("/api/settings")
s = resp2.get_json()["settings"]
self.assertTrue(s["external_api_public_mode"])
self.assertEqual(s["external_api_ip_whitelist"], ["10.0.0.1", "192.168.0.0/16"])
self.assertEqual(s["external_api_rate_limit_per_minute"], 30)
self.assertTrue(s["external_api_disable_raw_content"])
self.assertTrue(s["external_api_disable_wait_message"])