feat: finalize pool registration flow and docs

This commit is contained in:
胡绍康 committed 2026-03-17 20:02:21 +08:00
1 parent 6d95c74272
commit d50f01663c
27 files changed
+1268 -626

No files matched your search

+45 -120
View File
@@ -1,25 +1,35 @@
# Outlook Email Plus
个人自用的 Outlook 邮件管理与验证码提取工具,提供 Web 界面、多账号统一管理,并支持一组**受控的对外只读 API**(`/api/external/*`)。
一个用于统一管理 Outlook / IMAP 邮箱账号、读取邮件、提取验证码,并支持邮箱池调度的 Web 项目(or 注册机)。
适用场景:自用/小团队受控环境,用于管理自己的邮箱、读取邮件、提取验证码/验证链接、对接内部自动化脚本。**不建议直接公网裸露**。
## 核心能力(现状)
- **多邮箱统一管理**:批量导入、分组、标签、搜索、批量操作
- **多链路读信**:Microsoft Graph / IMAP(按账号类型与回退策略)
- **验证码/链接提取**:支持按来源(subject/body/html)、长度范围、正则等策略增强提取稳定性
- **定时任务**:Token 定时刷新、调度器心跳、Telegram 推送轮询、对外 API 异步探测轮询
- **Telegram 实时推送(可选)**:后台轮询新邮件并推送到 Telegram,支持 Message-ID 去重与“首次启用不轰炸”策略
- **临时邮箱(可选)**:对接 GPTMail 临时邮箱服务(生成/刷新/清理/拉取消息)
- **对外只读 API(可选)**:API Key 鉴权 + 公网模式守卫(IP 白名单、限流、高风险端点可禁用)
- **安全基线**:敏感数据加密存储、CSRF 防护、登录限速/锁定、审计日志、对外 API 访问审计
## 项目优势
## 快速开始
- 一套界面统一管理 Outlook OAuth 邮箱和 IMAP 邮箱(比如qq,gmail,163等等),减少多套工具切换成本
- 不只是“收件箱查看器”,还覆盖验证码提取、链接提取、注册池流转这些高频自动化场景
- 支持批量导入、分组、标签、搜索,适合多账号长期维护
- 可本地快速启动,也可直接 Docker 部署,落地成本低
- 提供受控对外接口和邮箱池能力,方便和注册、验证、自动化脚本衔接
### Docker(推荐)
## 主要功能
GHCR:
- 多邮箱账号管理:支持批量导入、分组、标签、搜索
- Outlook OAuth 与 IMAP 接入:兼容多种邮箱提供商
- 邮件读取与验证码提取:支持常见注册、登录、验证场景
- 邮箱池管理:支持领取、释放、完成、状态流转
- 可选扩展能力:Telegram 推送、临时邮箱、受控对外 API
## 适用场景
- 统一维护多个 Outlook / IMAP 邮箱账号
- 批量处理注册验证码、验证链接、通知邮件
- 把邮箱账号接入邮箱池后,供外部任务领取和回收
- 在受控环境中把邮件能力提供给内部脚本或自动化系统
## 部署方式
### Docker 部署
```bash
docker pull ghcr.io/zeropointsix/outlook-email-plus:latest
@@ -33,30 +43,12 @@ docker run -d \
ghcr.io/zeropointsix/outlook-email-plus:latest
```
Docker Hub(如已同步启用):
```bash
docker pull guangshanshui/outlook-email-plus:latest
```
PowerShell:
```powershell
docker run -d `
--name outlook-email-plus `
-p 5000:5000 `
-v ${PWD}/data:/app/data `
-e SECRET_KEY=your-secret-key-here `
-e LOGIN_PASSWORD=your-login-password `
ghcr.io/zeropointsix/outlook-email-plus:latest
```
说明:
- 强烈建议将 `data/` 挂载为持久化目录(数据库与运行数据)
- `SECRET_KEY` 用于会话与敏感字段加密。**丢失后无法解密旧数据**,请妥善保存并保持稳定
- 建议把 `data/` 挂载出来做持久化
- `SECRET_KEY` 请使用稳定且安全的值
### 本地运行
### 本地部署
```bash
python -m venv .venv
@@ -64,88 +56,13 @@ pip install -r requirements.txt
python start.py
```
`start.py` 会在缺少 `.env` 时自动从 `.env.example` 复制,并在 `SECRET_KEY` 为空/占位符时自动生成。
常用环境变量:
## 配置说明
优先通过环境变量或 `.env` 配置(参考 `.env.example`)。常用配置如下:
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `SECRET_KEY` | 必填:会话/加密密钥(务必备份) | 无 |
| `LOGIN_PASSWORD` | 登录密码(首次启动会哈希存储) | `admin123` |
| `HOST` | 监听地址 | `0.0.0.0` |
| `PORT` | 监听端口 | `5000` |
| `DATABASE_PATH` | SQLite 数据库路径 | `data/outlook_accounts.db` |
| `SCHEDULER_AUTOSTART` | 是否自动启动后台调度器 | `true` |
| `PROXY_FIX_ENABLED` | 是否启用 ProxyFix(反代部署才需要) | `false` |
| `TRUSTED_PROXIES` | 受信任代理列表(用于安全解析 `X-Forwarded-For`) | 空 |
| `GPTMAIL_BASE_URL` | GPTMail 临时邮箱服务地址 | `https://mail.chatgpt.org.uk` |
| `GPTMAIL_API_KEY` | GPTMail API Key | `gpt-test` |
| `OAUTH_CLIENT_ID` | OAuth 助手默认 client_id(可选覆盖) | 内置默认值 |
| `OAUTH_REDIRECT_URI` | OAuth 助手重定向地址(可选覆盖) | `http://localhost:8080` |
生成 `SECRET_KEY`:
```bash
python -c "import secrets; print(secrets.token_hex(32))"
```
## 账号接入与使用
1. 在 Web 页面中添加/导入账号(Outlook 或 IMAP)
2. Outlook 账号需要 `client_id` 与 `refresh_token`(可在设置中使用 OAuth 助手换取 Refresh Token)
3. 选择分组/账号后拉取邮件,或直接提取验证码/验证链接
4. 如需后台能力(定时刷新/Telegram 推送/异步 probe),保持调度器开启(`SCHEDULER_AUTOSTART=true`)
常见导入格式:
```text
user@outlook.com----password123----client_id----refresh_token
```
## 对外只读 API(`/api/external/*`)
对外 API 仅用于**受控环境**调用,统一使用 `X-API-Key` 请求头鉴权(不依赖登录态)。API Key 可在「设置」中配置:
- 兼容旧模式:单个 legacy key(`external_api_key`)
- 推荐模式:多 Key 管理(可启停/删除、可配置允许访问的邮箱白名单)
常用端点:
- `GET /api/external/health`:健康检查(含版本号)
- `GET /api/external/capabilities`:能力与受限端点说明
- `GET /api/external/account-status?email=...`:账号可读性/上游探测摘要
- `GET /api/external/messages?email=...&folder=inbox&skip=0&top=20`
- `GET /api/external/messages/latest?email=...`
- `GET /api/external/messages/<message_id>` / `.../<message_id>/raw`
- `GET /api/external/verification-code?email=...`
- `GET /api/external/verification-link?email=...`
- `GET /api/external/wait-message?email=...&timeout_seconds=30&poll_interval=5`
- 同步模式:默认阻塞等待
- 异步模式:`mode=async` 返回 `probe_id`(202),再轮询 `GET /api/external/probe/<probe_id>`
最小调用示例:
```bash
curl -H "X-API-Key: <your-key>" http://localhost:5000/api/external/health
```
### 公网模式守卫(强烈建议)
如果你确实要在更开放的网络环境使用对外 API,请至少:
1. 通过设置开启「公网模式」
2. 配置 **IP/CIDR 白名单**(非白名单将返回 403)
3. 配置 **按 IP 限流**(默认每分钟 60)
4. 视需要禁用高风险端点:`raw_content`、`wait_message`
5. 放在反向代理之后,并只在可信代理场景下启用 `PROXY_FIX_ENABLED=true`,同时设置 `TRUSTED_PROXIES`(否则不要信任 `X-Forwarded-For`)
## 开发与测试
```bash
python -m unittest discover -s tests -v
```
- `SECRET_KEY`:会话与敏感字段加密密钥
- `LOGIN_PASSWORD`:后台登录密码
- `DATABASE_PATH`:SQLite 数据库路径
- `OAUTH_CLIENT_ID`:Outlook OAuth 应用 ID
- `OAUTH_REDIRECT_URI`:Outlook OAuth 回调地址
## 界面预览
@@ -154,14 +71,22 @@ python -m unittest discover -s tests -v
![提取验证码](img/提取验证码.png)
![设置界面](img/设置界面.png)
## 技术栈
## 项目文档
- [注册与邮箱池接口文档](./注册与邮箱池接口文档.md)
如果你需要接入注册机之类的批量工作,请直接看上面的文档
## 感谢
本项目基于以下开源技术和服务能力构建:
- Flask
- SQLite
- Microsoft Graph API / IMAP
- Microsoft Graph API
- IMAP
- APScheduler
- 原生 JavaScript
## 许可证
MIT
Apache License 2.0
+34
View File
@@ -1,5 +1,39 @@
# DEVLOG
## v1.8.0 - 邮箱池与受控对外池 API 首次交付
发布日期:2026-03-17
### 新增功能
- 新增内部邮箱池接口:`/api/pool/claim-random`、`/api/pool/claim-release`、`/api/pool/claim-complete`、`/api/pool/stats`,支持随机领取、人工释放、结果回写与池统计。
- 新增对外邮箱池接口:`/api/external/pool/*` 现已支持 API Key 鉴权访问,并接入既有公网模式守卫、访问审计与调用方日级使用统计。
- 新增邮箱池状态机与持久化结构:账号新增 `pool_status`、`claimed_by`、`lease_expires_at`、`claim_token`、成功/失败计数等字段,同时引入 `account_claim_logs` 记录 claim/release/complete/expire 全链路动作。
- 新增多 API Key 粒度权限:`external_api_keys` 现支持 `pool_access` 字段,可按调用方单独授予 external pool 访问能力。
### 修复
- 修正对外邮箱池接口的返回格式,使 `claim-random`、`claim-release`、`claim-complete` 与 `stats` 全部对齐现有 external API contract,避免对接方处理分支不一致。
- 修正设置接口对邮箱池总开关和公网模式细粒度禁用项的读写逻辑,确保 `pool_external_enabled` 与 `external_api_disable_pool_*` 系列配置可以稳定持久化并回显。
- 修正租约超时回收行为,过期 claim 会自动写入 claim log、转入 `cooldown`,降低因调用方异常退出导致账号长期悬挂的风险。
### 重要变更
- 版本号从 `1.7.0` 提升到 `1.8.0`,应用 UI 侧边栏版本显示、系统/对外 API 返回的 `version` 字段继续由 `outlook_web.__version__` 统一驱动。
- 数据库 schema 新增邮箱池相关字段、`account_claim_logs` 表,以及 `external_api_keys.pool_access` 权限列;现有库初始化/升级时会自动补齐。
- 当前仓库不是 Tauri 工程,不包含 `Cargo.toml`、`package.json`、MSI 或 NSIS 构建链路;本次发布继续沿用仓库既有的 Docker 镜像 tar 与源码 zip 作为正式产物。
### 测试/验证
- 单元测试:`python -m unittest discover -s tests -v`
- 结果:`Ran 440 tests in 42.599s`
- 状态:全部通过
- 构建验证:`docker build -t outlook-email-plus:v1.8.0 .`
- 状态:失败
- 原因:Docker daemon 未启动,`//./pipe/dockerDesktopLinuxEngine` 不存在,当前环境无法连接 Docker Desktop Linux Engine
- 发布产物:
- 未生成。由于镜像构建失败,本次未导出 Docker tar、源码 zip,也未同步到 GitHub Release 页面。
## v1.7.0 - 第二次发布:README 交付口径补全
发布日期:2026-03-15
+1 -1
View File
@@ -1,4 +1,4 @@
__version__ = "1.7.0"
__version__ = "1.8.0"
from outlook_web.app import create_app
+4 -14
View File
@@ -90,9 +90,7 @@ def create_app(*, autostart_scheduler: Optional[bool] = None):
# ProxyFix 中间件(仅在配置启用时应用)
# 注意:启用前必须配置 TRUSTED_PROXIES 环境变量
if config.get_proxy_fix_enabled():
app.wsgi_app = ProxyFix(
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
# DB teardown(请求结束释放连接)
register_db(app)
@@ -102,11 +100,7 @@ def create_app(*, autostart_scheduler: Optional[bool] = None):
import sys
_log_handler = logging.StreamHandler(sys.stderr)
_log_handler.setFormatter(
logging.Formatter(
"%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%H:%M:%S"
)
)
_log_handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%H:%M:%S"))
_ow_logger = logging.getLogger("outlook_web")
if not _ow_logger.handlers:
_ow_logger.addHandler(_log_handler)
@@ -151,15 +145,11 @@ def create_app(*, autostart_scheduler: Optional[bool] = None):
from outlook_web.services import scheduler as scheduler_service
if scheduler_service.should_autostart_scheduler():
scheduler_service.init_scheduler(
_APP_INSTANCE, graph_service.test_refresh_token_with_rotation
)
scheduler_service.init_scheduler(_APP_INSTANCE, graph_service.test_refresh_token_with_rotation)
elif autostart_scheduler:
from outlook_web.services import graph as graph_service
from outlook_web.services import scheduler as scheduler_service
scheduler_service.init_scheduler(
_APP_INSTANCE, graph_service.test_refresh_token_with_rotation
)
scheduler_service.init_scheduler(_APP_INSTANCE, graph_service.test_refresh_token_with_rotation)
return _APP_INSTANCE
+18 -2
View File
@@ -51,6 +51,17 @@ def sanitize_input(text: str, max_length: int = 500) -> str:
return text
def _parse_bool_flag(value: Any, default: bool = False) -> bool:
"""解析请求中的布尔开关,兼容 bool / 数字 / 字符串。"""
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
return str(value).strip().lower() in {"1", "true", "yes", "on"}
# ==================== 账号基础 CRUD API ====================
@@ -168,13 +179,14 @@ def api_add_account() -> Any:
provider = (data.get("provider") or "outlook").strip().lower()
custom_imap_host = (data.get("imap_host") or "").strip()
custom_imap_port = data.get("imap_port")
add_to_pool = _parse_bool_flag(data.get("add_to_pool"), default=False)
if not account_str:
return jsonify({"success": False, "error": "请输入账号信息"})
# FD-00006: auto 模式允许 group_id=null(自动分组),需在分组校验前分流
if provider == "auto":
return _handle_auto_import(data)
return _handle_auto_import(data, add_to_pool=add_to_pool)
# 校验分组
target_group = groups_repo.get_group_by_id(group_id)
@@ -352,6 +364,7 @@ def api_add_account() -> Any:
imap_host=imap_host,
imap_port=imap_port,
imap_password=imap_pwd,
add_to_pool=add_to_pool,
db=db,
commit=False,
)
@@ -455,6 +468,7 @@ def api_add_account() -> Any:
client_id,
refresh_token,
group_id,
add_to_pool=add_to_pool,
db=db,
commit=False,
)
@@ -743,7 +757,7 @@ def _handle_gptmail_import(
return ok
def _handle_auto_import(data: Dict[str, Any]) -> Any:
def _handle_auto_import(data: Dict[str, Any], *, add_to_pool: bool = False) -> Any:
"""处理 provider="auto" 的智能混合导入。"""
account_str = data.get("account_string", "")
duplicate_strategy = (data.get("duplicate_strategy") or "skip").strip().lower()
@@ -883,6 +897,7 @@ def _handle_auto_import(data: Dict[str, Any]) -> Any:
group_id=group_id,
account_type="outlook",
provider="outlook",
add_to_pool=add_to_pool,
)
elif result["type"] == "imap":
ok = accounts_repo.add_account(
@@ -896,6 +911,7 @@ def _handle_auto_import(data: Dict[str, Any]) -> Any:
imap_host=fields.get("imap_host", ""),
imap_port=fields.get("imap_port", 993),
imap_password=fields.get("imap_password", ""),
add_to_pool=add_to_pool,
)
else:
ok = False
+8 -27
View File
@@ -5,8 +5,7 @@ from typing import Any
from flask import jsonify, request
from outlook_web.repositories import settings as settings_repo
from outlook_web.security.auth import api_key_required
from outlook_web.security.auth import get_external_api_consumer
from outlook_web.security.auth import api_key_required, get_external_api_consumer
from outlook_web.security.external_api_guard import external_api_guards
from outlook_web.services import external_api as external_api_service
from outlook_web.services.pool import (
@@ -18,9 +17,7 @@ from outlook_web.services.pool import (
)
def _audit(
endpoint: str, status: str, *, details: dict[str, Any], email_addr: str = ""
) -> None:
def _audit(endpoint: str, status: str, *, details: dict[str, Any], email_addr: str = "") -> None:
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr=email_addr,
@@ -148,16 +145,12 @@ def api_external_pool_claim_release():
if account_id is None:
_audit(endpoint, "error", details={"code": "ACCOUNT_ID_MISSING"})
return jsonify(
external_api_service.fail("ACCOUNT_ID_MISSING", "account_id 不能为空")
), 400
return jsonify(external_api_service.fail("ACCOUNT_ID_MISSING", "account_id 不能为空")), 400
try:
account_id = int(account_id)
except (TypeError, ValueError):
_audit(endpoint, "error", details={"code": "ACCOUNT_ID_INVALID"})
return jsonify(
external_api_service.fail("ACCOUNT_ID_INVALID", "account_id 必须为整数")
), 400
return jsonify(external_api_service.fail("ACCOUNT_ID_INVALID", "account_id 必须为整数")), 400
try:
release_claim(
@@ -168,11 +161,7 @@ def api_external_pool_claim_release():
reason=reason,
)
_audit(endpoint, "ok", details={"account_id": account_id})
return jsonify(
external_api_service.ok(
{"account_id": account_id, "pool_status": "available"}
)
)
return jsonify(external_api_service.ok({"account_id": account_id, "pool_status": "available"}))
except PoolServiceError as exc:
return _error_response(endpoint, exc)
except Exception as exc:
@@ -204,16 +193,12 @@ def api_external_pool_claim_complete():
if account_id is None:
_audit(endpoint, "error", details={"code": "ACCOUNT_ID_MISSING"})
return jsonify(
external_api_service.fail("ACCOUNT_ID_MISSING", "account_id 不能为空")
), 400
return jsonify(external_api_service.fail("ACCOUNT_ID_MISSING", "account_id 不能为空")), 400
try:
account_id = int(account_id)
except (TypeError, ValueError):
_audit(endpoint, "error", details={"code": "ACCOUNT_ID_INVALID"})
return jsonify(
external_api_service.fail("ACCOUNT_ID_INVALID", "account_id 必须为整数")
), 400
return jsonify(external_api_service.fail("ACCOUNT_ID_INVALID", "account_id 必须为整数")), 400
try:
new_status = complete_claim(
@@ -233,11 +218,7 @@ def api_external_pool_claim_complete():
"pool_status": new_status,
},
)
return jsonify(
external_api_service.ok(
{"account_id": account_id, "pool_status": new_status}
)
)
return jsonify(external_api_service.ok({"account_id": account_id, "pool_status": new_status}))
except PoolServiceError as exc:
return _error_response(endpoint, exc)
except Exception as exc:
+1 -3
View File
@@ -38,9 +38,7 @@ def _success_response(data: dict, http_status: int = 200):
def _error_response(error_code: str, message: str, http_status: int = 400):
"""统一失败响应:{"success": false, "error": "...", "message": "..."}"""
return jsonify(
{"success": False, "error": error_code, "message": message}
), http_status
return jsonify({"success": False, "error": error_code, "message": message}), http_status
def api_claim_random():
+29 -93
View File
@@ -27,9 +27,7 @@ def _mask_secret_value(value: str, head: int = 4, tail: int = 4) -> str:
safe_value = str(value)
if len(safe_value) <= head + tail:
return "*" * len(safe_value)
return (
safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:]
)
return safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:]
def _parse_allowed_emails_input(raw: Any) -> list[str]:
@@ -45,10 +43,7 @@ def _parse_allowed_emails_input(raw: Any) -> list[str]:
parsed = json.loads(text)
values = parsed if isinstance(parsed, list) else []
except (json.JSONDecodeError, TypeError):
values = [
item.strip()
for item in text.replace("\r", "\n").replace(",", "\n").split("\n")
]
values = [item.strip() for item in text.replace("\r", "\n").replace(",", "\n").split("\n")]
result: list[str] = []
seen: set[str] = set()
@@ -87,12 +82,9 @@ def api_get_settings() -> Any:
"refresh_delay_seconds": all_settings.get("refresh_delay_seconds", "5"),
"refresh_cron": all_settings.get("refresh_cron", "0 2 * * *"),
"use_cron_schedule": all_settings.get("use_cron_schedule", "false"),
"enable_scheduled_refresh": all_settings.get(
"enable_scheduled_refresh", "true"
),
"enable_scheduled_refresh": all_settings.get("enable_scheduled_refresh", "true"),
# 轮询配置
"enable_auto_polling": all_settings.get("enable_auto_polling", "false")
== "true",
"enable_auto_polling": all_settings.get("enable_auto_polling", "false") == "true",
"polling_interval": int(all_settings.get("polling_interval", "10")),
"polling_count": int(all_settings.get("polling_count", "5")),
}
@@ -101,9 +93,7 @@ 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()
external_api_keys = external_api_keys_repo.list_external_api_keys(
include_disabled=True
)
external_api_keys = external_api_keys_repo.list_external_api_keys(include_disabled=True)
usage_summary = external_api_keys_repo.get_external_api_usage_summary(
[item.get("consumer_key") or "" for item in external_api_keys]
)
@@ -121,45 +111,23 @@ def api_get_settings() -> Any:
)
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 ""
)
safe_settings["external_api_key_masked"] = _mask_secret_value(external_api_key_value) if external_api_key_value else ""
safe_settings["external_api_keys"] = external_api_keys
safe_settings["external_api_keys_count"] = len(external_api_keys)
safe_settings["external_api_multi_key_set"] = bool(external_api_keys)
# 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()
)
safe_settings["external_api_disable_pool_claim_random"] = (
settings_repo.get_external_api_disable_pool_claim_random()
)
safe_settings["external_api_disable_pool_claim_release"] = (
settings_repo.get_external_api_disable_pool_claim_release()
)
safe_settings["external_api_disable_pool_claim_complete"] = (
settings_repo.get_external_api_disable_pool_claim_complete()
)
safe_settings["external_api_disable_pool_stats"] = (
settings_repo.get_external_api_disable_pool_stats()
)
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()
safe_settings["external_api_disable_pool_claim_random"] = settings_repo.get_external_api_disable_pool_claim_random()
safe_settings["external_api_disable_pool_claim_release"] = settings_repo.get_external_api_disable_pool_claim_release()
safe_settings["external_api_disable_pool_claim_complete"] = settings_repo.get_external_api_disable_pool_claim_complete()
safe_settings["external_api_disable_pool_stats"] = settings_repo.get_external_api_disable_pool_stats()
safe_settings["pool_external_enabled"] = settings_repo.get_pool_external_enabled()
# Telegram 推送配置
@@ -167,25 +135,19 @@ def api_get_settings() -> Any:
if tg_bot_token_raw and is_encrypted(tg_bot_token_raw):
try:
plain_token = decrypt_data(tg_bot_token_raw)
safe_settings["telegram_bot_token"] = (
"****" + plain_token[-4:] if len(plain_token) > 4 else "****"
)
safe_settings["telegram_bot_token"] = "****" + plain_token[-4:] if len(plain_token) > 4 else "****"
except Exception:
safe_settings["telegram_bot_token"] = "****"
else:
safe_settings["telegram_bot_token"] = ""
safe_settings["telegram_chat_id"] = all_settings.get("telegram_chat_id", "")
safe_settings["telegram_poll_interval"] = int(
all_settings.get("telegram_poll_interval", "600") or "600"
)
safe_settings["telegram_poll_interval"] = int(all_settings.get("telegram_poll_interval", "600") or "600")
response = {"success": True, "settings": safe_settings}
# 同时在顶层暴露 telegram 字段(兼容前端直接访问)
response["telegram_bot_token"] = safe_settings.get("telegram_bot_token", "")
response["telegram_chat_id"] = safe_settings.get("telegram_chat_id", "")
response["telegram_poll_interval"] = safe_settings.get(
"telegram_poll_interval", 600
)
response["telegram_poll_interval"] = safe_settings.get("telegram_poll_interval", 600)
return jsonify(response)
@@ -209,11 +171,7 @@ def api_update_settings() -> Any:
pending_operations: list[Any] = []
def queue_setting_update(key: str, value: str) -> None:
pending_operations.append(
lambda key=key, value=value: settings_repo.set_setting(
key, value, commit=False
)
)
pending_operations.append(lambda key=key, value=value: settings_repo.set_setting(key, value, commit=False))
def queue_operation(op: Any) -> None:
pending_operations.append(op)
@@ -234,11 +192,7 @@ def api_update_settings() -> Any:
if "gptmail_api_key" in data:
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)
):
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:
queue_setting_update("gptmail_api_key", new_api_key)
@@ -273,10 +227,7 @@ def api_update_settings() -> Any:
errors.append("external_api_keys 必须是数组")
else:
existing_keys = {
int(item["id"]): item
for item in external_api_keys_repo.list_external_api_keys(
include_disabled=True
)
int(item["id"]): item for item in external_api_keys_repo.list_external_api_keys(include_disabled=True)
}
normalized_items: list[dict[str, Any]] = []
seen_names: set[str] = set()
@@ -320,13 +271,8 @@ def api_update_settings() -> Any:
api_key_value = None
allowed_emails = _parse_allowed_emails_input(item.get("allowed_emails"))
if (
item.get("allowed_emails") not in (None, "", [])
and not allowed_emails
):
errors.append(
f"external_api_keys[{index}].allowed_emails 至少包含一个合法邮箱"
)
if item.get("allowed_emails") not in (None, "", []) and not allowed_emails:
errors.append(f"external_api_keys[{index}].allowed_emails 至少包含一个合法邮箱")
continue
normalized_items.append(
@@ -335,9 +281,7 @@ def api_update_settings() -> Any:
"name": name,
"api_key": api_key_value,
"allowed_emails": allowed_emails,
"pool_access": _parse_bool_input(
item.get("pool_access"), default=False
),
"pool_access": _parse_bool_input(item.get("pool_access"), default=False),
"enabled": _parse_bool_input(item.get("enabled"), default=True),
}
)
@@ -665,9 +609,7 @@ def api_validate_cron() -> Any:
}
)
except Exception as e:
return jsonify(
{"success": False, "valid": False, "error": f"Cron 表达式无效: {str(e)}"}
)
return jsonify({"success": False, "valid": False, "error": f"Cron 表达式无效: {str(e)}"})
@login_required
@@ -679,17 +621,11 @@ def api_test_telegram() -> Any:
chat_id = settings_repo.get_setting("telegram_chat_id", "")
if not bot_token_raw or not chat_id:
return jsonify(
{"success": False, "error": "请先配置 Telegram Bot Token 和 Chat ID"}
)
return jsonify({"success": False, "error": "请先配置 Telegram Bot Token 和 Chat ID"})
bot_token = (
decrypt_data(bot_token_raw) if is_encrypted(bot_token_raw) else bot_token_raw
)
bot_token = decrypt_data(bot_token_raw) if is_encrypted(bot_token_raw) else bot_token_raw
ok = _send_telegram_message(
bot_token, chat_id, "✅ Outlook Email Plus 测试消息:配置正确!"
)
ok = _send_telegram_message(bot_token, chat_id, "✅ Outlook Email Plus 测试消息:配置正确!")
if ok:
log_audit("telegram_test", "settings", None, "测试消息发送成功")
return jsonify({"success": True, "message": "测试消息已发送,请检查 Telegram"})
+13 -39
View File
@@ -126,9 +126,7 @@ def init_db(database_path: Optional[str] = None):
""")
# 在锁内读取当前 schema 版本(保证一致性)
row = cursor.execute(
"SELECT value FROM settings WHERE key = ?", (DB_SCHEMA_VERSION_KEY,)
).fetchone()
row = cursor.execute("SELECT value FROM settings WHERE key = ?", (DB_SCHEMA_VERSION_KEY,)).fetchone()
current_version = int(row["value"]) if row and row["value"] is not None else 0
upgrading = current_version < DB_SCHEMA_VERSION
@@ -137,9 +135,7 @@ def init_db(database_path: Optional[str] = None):
if db_existed:
try:
print("=" * 60)
print(
f"[升级提示] 检测到数据库需要升级:v{current_version} -> v{DB_SCHEMA_VERSION}"
)
print(f"[升级提示] 检测到数据库需要升级:v{current_version} -> v{DB_SCHEMA_VERSION}")
print(f"[升级提示] 强烈建议先备份数据库文件:{path}")
print(f'[升级提示] 示例:cp "{path}" "{path}.backup"')
print(f"[升级提示] trace_id={migration_trace_id}")
@@ -353,39 +349,25 @@ def init_db(database_path: Optional[str] = None):
if "remark" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN remark TEXT")
if "status" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT 'active'"
)
cursor.execute("ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT 'active'")
if "updated_at" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
)
cursor.execute("ALTER TABLE accounts ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
if "last_refresh_at" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN last_refresh_at TIMESTAMP")
if "account_type" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN account_type TEXT DEFAULT 'outlook'"
)
cursor.execute("ALTER TABLE accounts ADD COLUMN account_type TEXT DEFAULT 'outlook'")
if "provider" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN provider TEXT DEFAULT 'outlook'"
)
cursor.execute("ALTER TABLE accounts ADD COLUMN provider TEXT DEFAULT 'outlook'")
if "imap_host" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN imap_host TEXT")
if "imap_port" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN imap_port INTEGER DEFAULT 993"
)
cursor.execute("ALTER TABLE accounts ADD COLUMN imap_port INTEGER DEFAULT 993")
if "imap_password" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN imap_password TEXT")
if "telegram_push_enabled" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN telegram_push_enabled INTEGER NOT NULL DEFAULT 0"
)
cursor.execute("ALTER TABLE accounts ADD COLUMN telegram_push_enabled INTEGER NOT NULL DEFAULT 0")
if "telegram_last_checked_at" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN telegram_last_checked_at TEXT DEFAULT NULL"
)
cursor.execute("ALTER TABLE accounts ADD COLUMN telegram_last_checked_at TEXT DEFAULT NULL")
cursor.execute("PRAGMA table_info(groups)")
group_columns = [col[1] for col in cursor.fetchall()]
@@ -632,9 +614,7 @@ def init_db(database_path: Optional[str] = None):
cursor.execute("PRAGMA table_info(external_api_keys)")
external_api_keys_columns = [col[1] for col in cursor.fetchall()]
if "pool_access" not in external_api_keys_columns:
cursor.execute(
"ALTER TABLE external_api_keys ADD COLUMN pool_access INTEGER NOT NULL DEFAULT 0"
)
cursor.execute("ALTER TABLE external_api_keys ADD COLUMN pool_access INTEGER NOT NULL DEFAULT 0")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_external_api_keys_enabled
ON external_api_keys(enabled, updated_at)
@@ -683,9 +663,7 @@ def init_db(database_path: Optional[str] = None):
("fail_count", "INTEGER DEFAULT 0"),
]:
if col_def[0] not in accounts_columns_v11:
cursor.execute(
f"ALTER TABLE accounts ADD COLUMN {col_def[0]} {col_def[1]}"
)
cursor.execute(f"ALTER TABLE accounts ADD COLUMN {col_def[0]} {col_def[1]}")
cursor.execute("""
CREATE TABLE IF NOT EXISTS account_claim_logs (
@@ -718,12 +696,8 @@ def init_db(database_path: Optional[str] = None):
ON accounts(pool_status)
""")
cursor.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('pool_cooldown_seconds', '86400')"
)
cursor.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('pool_default_lease_seconds', '600')"
)
cursor.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('pool_cooldown_seconds', '86400')")
cursor.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('pool_default_lease_seconds', '600')")
# 迁移现有明文数据为加密数据
migrate_sensitive_data(conn)
+5 -2
View File
@@ -162,6 +162,7 @@ def add_account(
imap_host: str = "",
imap_port: int = 993,
imap_password: str = "",
add_to_pool: bool = False,
db: Optional[sqlite3.Connection] = None,
commit: bool = True,
) -> bool:
@@ -186,15 +187,16 @@ def add_account(
encrypted_password = encrypt_data(password) if password else password
encrypted_refresh_token = encrypt_data(refresh_token) if refresh_token else refresh_token
encrypted_imap_password = encrypt_data(imap_password) if imap_password else imap_password
initial_pool_status = "available" if add_to_pool else None
db.execute(
"""
INSERT INTO accounts (
email, password, client_id, refresh_token,
account_type, provider, imap_host, imap_port, imap_password,
group_id, remark
group_id, remark, pool_status
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
email_addr,
@@ -208,6 +210,7 @@ def add_account(
encrypted_imap_password,
group_id,
remark,
initial_pool_status,
),
)
if commit:
+10 -37
View File
@@ -15,9 +15,7 @@ def _mask_secret_value(value: str, head: int = 4, tail: int = 4) -> str:
safe_value = str(value)
if len(safe_value) <= head + tail:
return "*" * len(safe_value)
return (
safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:]
)
return safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:]
def _parse_allowed_emails(raw: Any) -> list[str]:
@@ -28,10 +26,7 @@ def _parse_allowed_emails(raw: Any) -> list[str]:
try:
values = json.loads(raw)
except (json.JSONDecodeError, TypeError):
values = [
item.strip()
for item in raw.replace("\r", "\n").replace(",", "\n").split("\n")
]
values = [item.strip() for item in raw.replace("\r", "\n").replace(",", "\n").split("\n")]
if not isinstance(values, list):
return []
@@ -48,9 +43,7 @@ def _parse_allowed_emails(raw: Any) -> list[str]:
def _allowed_emails_json(allowed_emails: Iterable[str] | None) -> str:
return json.dumps(
_parse_allowed_emails(list(allowed_emails or [])), ensure_ascii=False
)
return json.dumps(_parse_allowed_emails(list(allowed_emails or [])), ensure_ascii=False)
def _coerce_bool(value: Any, default: bool = True) -> bool:
@@ -190,9 +183,7 @@ def update_external_api_key(
(int(key_id),),
).fetchone()["api_key_encrypted"]
),
_allowed_emails_json(
existing["allowed_emails"] if allowed_emails is None else allowed_emails
),
_allowed_emails_json(existing["allowed_emails"] if allowed_emails is None else allowed_emails),
int(
_coerce_bool(
existing["pool_access"] if pool_access is None else pool_access,
@@ -221,9 +212,7 @@ def delete_external_api_key(key_id: int, *, commit: bool = True) -> bool:
return cursor.rowcount > 0
def replace_external_api_keys(
items: list[dict[str, Any]], *, commit: bool = True
) -> list[dict[str, Any]]:
def replace_external_api_keys(items: list[dict[str, Any]], *, commit: bool = True) -> list[dict[str, Any]]:
existing_rows = list_external_api_keys(include_disabled=True)
existing_ids = {int(item["id"]): item for item in existing_rows}
seen_ids: set[int] = set()
@@ -257,12 +246,8 @@ def replace_external_api_keys(
update_external_api_key(
key_id,
name=name,
api_key=None
if api_key in (None, "") and existing.get("api_key_masked")
else api_key,
allowed_emails=_parse_allowed_emails(allowed_emails)
if allowed_emails is not None
else existing["allowed_emails"],
api_key=None if api_key in (None, "") and existing.get("api_key_masked") else api_key,
allowed_emails=_parse_allowed_emails(allowed_emails) if allowed_emails is not None else existing["allowed_emails"],
pool_access=pool_access,
enabled=enabled,
commit=False,
@@ -310,12 +295,7 @@ def find_external_api_key_by_plaintext(provided_key: str) -> dict[str, Any] | No
def mark_external_api_key_used(key_id: int) -> None:
db = get_db()
used_at = (
datetime.now(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
used_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
db.execute(
"""
UPDATE external_api_keys
@@ -338,12 +318,7 @@ def record_external_api_consumer_usage(
return
db = get_db()
usage_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
last_used_at = (
datetime.now(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
last_used_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
success_inc = 1 if str(status or "").lower() == "ok" else 0
error_inc = 0 if success_inc else 1
db.execute(
@@ -380,9 +355,7 @@ def record_external_api_consumer_usage(
def get_external_api_usage_summary(
consumer_keys: list[str],
) -> dict[str, dict[str, Any]]:
clean_keys = [
str(item or "").strip() for item in consumer_keys if str(item or "").strip()
]
clean_keys = [str(item or "").strip() for item in consumer_keys if str(item or "").strip()]
if not clean_keys:
return {}
+6 -13
View File
@@ -1,12 +1,10 @@
from __future__ import annotations
import secrets
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
import sqlite3
RESULT_TO_POOL_STATUS: Dict[str, str] = {
"success": "used",
"verification_timeout": "cooldown",
@@ -57,9 +55,7 @@ def claim_atomic(
params.append(tag_name)
if exclude_recent_minutes and exclude_recent_minutes > 0:
cutoff = (
_utcnow() - timedelta(minutes=exclude_recent_minutes)
).isoformat() + "Z"
cutoff = (_utcnow() - timedelta(minutes=exclude_recent_minutes)).isoformat() + "Z"
sql += " AND (a.last_claimed_at IS NULL OR a.last_claimed_at < ?)"
params.append(cutoff)
@@ -73,9 +69,7 @@ def claim_atomic(
return None
now_str = _utcnow().isoformat() + "Z"
lease_expires_at_str = (
_utcnow() + timedelta(seconds=lease_seconds)
).isoformat() + "Z"
lease_expires_at_str = (_utcnow() + timedelta(seconds=lease_seconds)).isoformat() + "Z"
token = "clm_" + secrets.token_urlsafe(9)
conn.execute(
@@ -259,12 +253,10 @@ def recover_cooldown(conn: sqlite3.Connection, cooldown_seconds: int) -> int:
def get_stats(conn: sqlite3.Connection) -> dict:
rows = conn.execute(
"""
rows = conn.execute("""
SELECT pool_status, COUNT(*) as cnt FROM accounts
GROUP BY pool_status
"""
).fetchall()
""").fetchall()
pool_counts: dict = {
"available": 0,
"claimed": 0,
@@ -274,6 +266,7 @@ def get_stats(conn: sqlite3.Connection) -> dict:
"retired": 0,
}
for row in rows:
# external API 只暴露池内状态;NULL/池外账号不应出现在契约里。
key = row["pool_status"]
if key in pool_counts:
pool_counts[key] = row["cnt"]
+4 -14
View File
@@ -79,9 +79,7 @@ def get_external_api_key_masked(head: int = 4, tail: int = 4) -> str:
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:]
)
return safe_value[:head] + ("*" * (len(safe_value) - head - tail)) + safe_value[-tail:]
# ── P1:公网模式安全配置 ──────────────────────────────
@@ -128,23 +126,15 @@ def get_pool_external_enabled() -> bool:
def get_external_api_disable_pool_claim_random() -> bool:
return (
get_setting("external_api_disable_pool_claim_random", "false").lower() == "true"
)
return get_setting("external_api_disable_pool_claim_random", "false").lower() == "true"
def get_external_api_disable_pool_claim_release() -> bool:
return (
get_setting("external_api_disable_pool_claim_release", "false").lower()
== "true"
)
return get_setting("external_api_disable_pool_claim_release", "false").lower() == "true"
def get_external_api_disable_pool_claim_complete() -> bool:
return (
get_setting("external_api_disable_pool_claim_complete", "false").lower()
== "true"
)
return get_setting("external_api_disable_pool_claim_complete", "false").lower() == "true"
def get_external_api_disable_pool_stats() -> bool:
+6
View File
@@ -8,6 +8,11 @@ from outlook_web.controllers import external_pool as external_pool_controller
def create_blueprint(csrf_exempt: Optional[Callable] = None) -> Blueprint:
"""创建 external_pool Blueprint。
外部池接口面向 worker/合作方直接调用,POST 请求不应被浏览器态 CSRF 校验拦截。
这里统一在 Blueprint 装配阶段透传 csrf_exempt,避免后续新增端点时遗漏。
"""
bp = Blueprint("external_pool", __name__)
handlers = [
(
@@ -32,6 +37,7 @@ def create_blueprint(csrf_exempt: Optional[Callable] = None) -> Blueprint:
),
]
# 所有 external_pool 端点都走同一套鉴权链路,这里只负责统一挂载 CSRF 豁免。
for path, handler, methods in handlers:
view_func = csrf_exempt(handler) if csrf_exempt else handler
bp.add_url_rule(path, view_func=view_func, methods=methods)
+9 -30
View File
@@ -163,9 +163,7 @@ def login_required(f):
trace_id=trace_id_value,
)
return (
jsonify(
{"success": False, "error": error_payload, "need_login": True}
),
jsonify({"success": False, "error": error_payload, "need_login": True}),
401,
)
return redirect(url_for("pages.login"))
@@ -205,20 +203,10 @@ def api_key_required(f):
401,
)
matched_consumer = external_api_keys_repo.find_external_api_key_by_plaintext(
provided_key
)
matched_consumer = external_api_keys_repo.find_external_api_key_by_plaintext(provided_key)
configured_key = settings_repo.get_external_api_key()
any_enabled_multi_key_configured = (
external_api_keys_repo.has_any_external_api_key_configured(
enabled_only=True
)
)
if (
not matched_consumer
and not configured_key
and not any_enabled_multi_key_configured
):
any_enabled_multi_key_configured = external_api_keys_repo.has_any_external_api_key_configured(enabled_only=True)
if not matched_consumer and not configured_key and not any_enabled_multi_key_configured:
return (
jsonify(
{
@@ -232,13 +220,10 @@ def api_key_required(f):
)
if matched_consumer:
external_api_keys_repo.mark_external_api_key_used(
int(matched_consumer["id"])
)
external_api_keys_repo.mark_external_api_key_used(int(matched_consumer["id"]))
g.external_api_consumer = {
"id": matched_consumer["id"],
"consumer_key": matched_consumer.get("consumer_key")
or f"key:{matched_consumer['id']}",
"consumer_key": matched_consumer.get("consumer_key") or f"key:{matched_consumer['id']}",
"name": matched_consumer.get("name") or f"key-{matched_consumer['id']}",
"source": "external_api_keys",
"allowed_emails": matched_consumer.get("allowed_emails") or [],
@@ -248,9 +233,7 @@ def api_key_required(f):
}
return f(*args, **kwargs)
if not configured_key or not secrets.compare_digest(
str(provided_key), str(configured_key)
):
if not configured_key or not secrets.compare_digest(str(provided_key), str(configured_key)):
return (
jsonify(
{
@@ -373,9 +356,7 @@ def issue_export_verify_token(client_ip: str, user_agent: str) -> str:
return verify_token
def consume_export_verify_token(
verify_token: str, client_ip: str = "", user_agent: str = ""
) -> tuple[bool, str]:
def consume_export_verify_token(verify_token: str, client_ip: str = "", user_agent: str = "") -> tuple[bool, str]:
"""
校验并消费一次性导出验证 token(成功则删除)
@@ -406,9 +387,7 @@ def consume_export_verify_token(
expires_at = row["expires_at"] or 0
if float(expires_at) < now_ts:
db.execute(
"DELETE FROM export_verify_tokens WHERE token = ?", (verify_token,)
)
db.execute("DELETE FROM export_verify_tokens WHERE token = ?", (verify_token,))
db.commit()
return False, "验证已过期,请重新验证"
+9 -27
View File
@@ -36,27 +36,21 @@ def _validate_caller_id(caller_id: str) -> None:
if not caller_id or not caller_id.strip():
raise PoolServiceError("caller_id 不能为空", "caller_id_empty")
if len(caller_id) > CALLER_ID_MAX_LEN:
raise PoolServiceError(
f"caller_id 超过最大长度 {CALLER_ID_MAX_LEN}", "caller_id_too_long"
)
raise PoolServiceError(f"caller_id 超过最大长度 {CALLER_ID_MAX_LEN}", "caller_id_too_long")
def _validate_task_id(task_id: str) -> None:
if not task_id or not task_id.strip():
raise PoolServiceError("task_id 不能为空", "task_id_empty")
if len(task_id) > TASK_ID_MAX_LEN:
raise PoolServiceError(
f"task_id 超过最大长度 {TASK_ID_MAX_LEN}", "task_id_too_long"
)
raise PoolServiceError(f"task_id 超过最大长度 {TASK_ID_MAX_LEN}", "task_id_too_long")
def _validate_lease_seconds(lease_seconds: int, max_lease: int = 3600) -> None:
if lease_seconds <= 0:
raise PoolServiceError("lease_seconds 必须大于 0", "lease_seconds_invalid")
if lease_seconds > max_lease:
raise PoolServiceError(
f"lease_seconds 不能超过 {max_lease} 秒", "lease_seconds_too_large"
)
raise PoolServiceError(f"lease_seconds 不能超过 {max_lease} 秒", "lease_seconds_too_large")
def _read_settings_via_conn(conn) -> dict:
@@ -97,9 +91,7 @@ def claim_random(
provider=provider,
)
if account is None:
raise PoolServiceError(
"池中没有符合条件的可用邮箱", "no_available_account", http_status=200
)
raise PoolServiceError("池中没有符合条件的可用邮箱", "no_available_account", http_status=200)
return account
finally:
conn.close()
@@ -119,9 +111,7 @@ def release_claim(
if not claim_token or not claim_token.strip():
raise PoolServiceError("claim_token 不能为空", "claim_token_empty")
if reason and len(reason) > REASON_MAX_LEN:
raise PoolServiceError(
f"reason 超过最大长度 {REASON_MAX_LEN}", "reason_too_long"
)
raise PoolServiceError(f"reason 超过最大长度 {REASON_MAX_LEN}", "reason_too_long")
conn = create_sqlite_connection()
try:
@@ -138,9 +128,7 @@ def release_claim(
http_status=409,
)
if row["claim_token"] != claim_token:
raise PoolServiceError(
"claim_token 不匹配", "token_mismatch", http_status=403
)
raise PoolServiceError("claim_token 不匹配", "token_mismatch", http_status=403)
expected_claimed_by = f"{caller_id}:{task_id}"
if row["claimed_by"] != expected_claimed_by:
raise PoolServiceError(
@@ -178,9 +166,7 @@ def complete_claim(
"invalid_result",
)
if detail and len(detail) > DETAIL_MAX_LEN:
raise PoolServiceError(
f"detail 超过最大长度 {DETAIL_MAX_LEN}", "detail_too_long"
)
raise PoolServiceError(f"detail 超过最大长度 {DETAIL_MAX_LEN}", "detail_too_long")
conn = create_sqlite_connection()
try:
@@ -197,9 +183,7 @@ def complete_claim(
http_status=409,
)
if row["claim_token"] != claim_token:
raise PoolServiceError(
"claim_token 不匹配", "token_mismatch", http_status=403
)
raise PoolServiceError("claim_token 不匹配", "token_mismatch", http_status=403)
expected_claimed_by = f"{caller_id}:{task_id}"
if row["claimed_by"] != expected_claimed_by:
raise PoolServiceError(
@@ -208,9 +192,7 @@ def complete_claim(
http_status=403,
)
new_status = pool_repo.complete(
conn, account_id, claim_token, caller_id, task_id, result, detail
)
new_status = pool_repo.complete(conn, account_id, claim_token, caller_id, task_id, result, detail)
return new_status
finally:
conn.close()
+19 -64
View File
@@ -160,9 +160,7 @@ def _configure_pool_maintenance_jobs(scheduler) -> None:
conn = create_sqlite_connection()
try:
row = conn.execute(
"SELECT value FROM settings WHERE key = 'pool_cooldown_seconds'"
).fetchone()
row = conn.execute("SELECT value FROM settings WHERE key = 'pool_cooldown_seconds'").fetchone()
cooldown_seconds = int(row["value"]) if row and row["value"] else 86400
recovered = pool_repo.recover_cooldown(conn, cooldown_seconds)
if recovered:
@@ -204,16 +202,9 @@ def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None:
return
with app.app_context():
enable_scheduled = (
settings_repo.get_setting("enable_scheduled_refresh", "true").lower()
== "true"
)
use_cron = (
settings_repo.get_setting("use_cron_schedule", "false").lower() == "true"
)
refresh_interval_days = int(
settings_repo.get_setting("refresh_interval_days", "30")
)
enable_scheduled = settings_repo.get_setting("enable_scheduled_refresh", "true").lower() == "true"
use_cron = settings_repo.get_setting("use_cron_schedule", "false").lower() == "true"
refresh_interval_days = int(settings_repo.get_setting("refresh_interval_days", "30"))
cron_expr = settings_repo.get_setting("refresh_cron", "0 2 * * *")
try:
@@ -259,9 +250,7 @@ def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None:
if len(parts) != 5:
raise ValueError("Cron 表达式格式错误,应为 5 段")
minute, hour, day, month, day_of_week = parts
trigger = CronTrigger(
minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week
)
trigger = CronTrigger(minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week)
scheduler.add_job(
func=scheduled_task,
trigger=trigger,
@@ -287,9 +276,7 @@ def configure_scheduler_jobs(scheduler, app, test_refresh_token) -> None:
coalesce=True,
misfire_grace_time=600,
)
print(
f"✓ 定时任务已配置:每天凌晨 2:00 检查刷新(周期:{refresh_interval_days} 天)"
)
print(f"✓ 定时任务已配置:每天凌晨 2:00 检查刷新(周期:{refresh_interval_days} 天)")
def init_scheduler(app, test_refresh_token):
@@ -332,17 +319,9 @@ def scheduled_refresh_task(app, test_refresh_token):
try:
with app.app_context():
enable_scheduled = (
settings_repo.get_setting("enable_scheduled_refresh", "true").lower()
== "true"
)
use_cron = (
settings_repo.get_setting("use_cron_schedule", "false").lower()
== "true"
)
refresh_interval_days = int(
settings_repo.get_setting("refresh_interval_days", "30")
)
enable_scheduled = settings_repo.get_setting("enable_scheduled_refresh", "true").lower() == "true"
use_cron = settings_repo.get_setting("use_cron_schedule", "false").lower() == "true"
refresh_interval_days = int(settings_repo.get_setting("refresh_interval_days", "30"))
delay_seconds = int(settings_repo.get_setting("refresh_delay_seconds", "5"))
run_id = create_refresh_run(conn, "scheduled", trace_id, total=0)
@@ -384,19 +363,13 @@ def scheduled_refresh_task(app, test_refresh_token):
return
# 获取刷新间隔配置
delay_row = conn.execute(
"SELECT value FROM settings WHERE key = 'refresh_delay_seconds'"
).fetchone()
delay_row = conn.execute("SELECT value FROM settings WHERE key = 'refresh_delay_seconds'").fetchone()
delay_seconds = int(delay_row["value"]) if delay_row else 5
# 清理超过半年的刷新记录
try:
conn.execute(
"DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')"
)
conn.execute(
"DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),)
)
conn.execute("DELETE FROM account_refresh_logs WHERE created_at < datetime('now', '-6 months')")
conn.execute("DELETE FROM distributed_locks WHERE expires_at < ?", (time.time(),))
conn.commit()
except Exception:
pass
@@ -419,13 +392,9 @@ def scheduled_refresh_task(app, test_refresh_token):
ttl_seconds = max(60 * 60 * 2, estimated)
ttl_seconds = min(ttl_seconds, 60 * 60 * 24)
ok, lock_info = acquire_distributed_lock(
conn, REFRESH_LOCK_NAME, lock_owner_id, ttl_seconds
)
ok, lock_info = acquire_distributed_lock(conn, REFRESH_LOCK_NAME, lock_owner_id, ttl_seconds)
if not ok:
finish_refresh_run(
conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行"
)
finish_refresh_run(conn, run_id, "skipped", total, 0, 0, "刷新任务冲突:已有刷新在执行")
return
lock_acquired = True
@@ -440,11 +409,7 @@ def scheduled_refresh_task(app, test_refresh_token):
# 解密 refresh_token
try:
refresh_token = (
decrypt_data(encrypted_refresh_token)
if encrypted_refresh_token
else encrypted_refresh_token
)
refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token
except Exception as e:
failed_count += 1
error_msg = f"解密 token 失败: {str(e)}"
@@ -473,17 +438,13 @@ def scheduled_refresh_task(app, test_refresh_token):
group_id = account["group_id"]
if group_id:
try:
group_row = conn.execute(
"SELECT proxy_url FROM groups WHERE id = ?", (group_id,)
).fetchone()
group_row = conn.execute("SELECT proxy_url FROM groups WHERE id = ?", (group_id,)).fetchone()
if group_row:
proxy_url = group_row["proxy_url"] or ""
except Exception:
proxy_url = ""
success, error_msg, new_refresh_token = test_refresh_token(
client_id, refresh_token, proxy_url
)
success, error_msg, new_refresh_token = test_refresh_token(client_id, refresh_token, proxy_url)
try:
conn.execute(
@@ -503,11 +464,7 @@ def scheduled_refresh_task(app, test_refresh_token):
if success:
# refresh token 可能滚动更新:保存新的 refresh_token(加密存储)
if (
isinstance(new_refresh_token, str)
and new_refresh_token.strip()
and new_refresh_token != refresh_token
):
if isinstance(new_refresh_token, str) and new_refresh_token.strip() and new_refresh_token != refresh_token:
conn.execute(
"""
UPDATE accounts
@@ -570,9 +527,7 @@ def should_autostart_scheduler() -> bool:
return False
# Flask CLI (flask run) + reloader:父进程不启动
if config.env_true("FLASK_RUN_FROM_CLI", False) and not config.env_true(
"WERKZEUG_RUN_MAIN", False
):
if config.env_true("FLASK_RUN_FROM_CLI", False) and not config.env_true("WERKZEUG_RUN_MAIN", False):
return False
return True
+6 -1
View File
@@ -162,6 +162,10 @@
// 显示添加账号模态框
function showAddAccountModal() {
document.getElementById('accountInput').value = '';
const addToPoolCheckbox = document.getElementById('addToPoolCheckbox');
if (addToPoolCheckbox) {
addToPoolCheckbox.checked = false;
}
// 设置默认分组为当前选中的分组
if (currentGroupId) {
document.getElementById('importGroupSelect').value = currentGroupId;
@@ -203,6 +207,7 @@
const groupId = parseInt(document.getElementById('importGroupSelect').value);
const providerEl = document.getElementById('accountProvider');
const provider = providerEl ? (providerEl.value || 'outlook') : 'outlook';
const addToPool = Boolean(document.getElementById('addToPoolCheckbox')?.checked);
if (!input) {
showToast('请输入账号信息', 'error');
@@ -210,7 +215,7 @@
}
try {
const payload = { account_string: input, group_id: groupId };
const payload = { account_string: input, group_id: groupId, add_to_pool: addToPool };
if (provider === 'auto') {
payload.provider = 'auto';
+7
View File
@@ -115,6 +115,13 @@
placeholder="邮箱----密码----client_id----refresh_token"></textarea>
<div class="form-hint" id="accountFormatHint">格式:邮箱----密码----client_id----refresh_token,支持批量导入(每行一个)</div>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="addToPoolCheckbox">
<span class="form-label" style="margin: 0;">导入后加入邮箱池</span>
</label>
<div class="form-hint">勾选后,新导入的 Outlook/IMAP 账号会以 `available` 状态进入邮箱池;不勾选则保持池外。</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="hideAddAccountModal()">取消</button>
+190
View File
@@ -0,0 +1,190 @@
# 注册 Pool 人工测试清单
## 目标
验证“通过页面注册 Outlook 账号后,该账号能够进入邮箱池并可被 `/api/pool/*` 或 `/api/external/pool/*` 正常领取”的完整链路。
## 当前检查结论
当前代码已切换为“显式加入池”模式:
1. 前端存在“`🔑 注册`”按钮,可触发 OAuth 获取 Token。
2. 导入弹窗新增“`导入后加入邮箱池`”复选框。
3. 勾选时,新导入的 Outlook/IMAP 账号会写入 `pool_status='available'`。
4. 不勾选时,账号保持池外,`pool_status` 为 `NULL`。
结论:当前版本不再采用“注册即自动入池”,而是由用户在导入时显式决定是否加入 Pool。
## 代码定位
- 前端注册入口:
- `templates/index.html`
- `static/js/features/groups.js`
- `static/js/main.js`
- 账号新增入库:
- `outlook_web/repositories/accounts.py`
- 池领取条件:
- `outlook_web/repositories/pool.py`
- 数据库字段默认值:
- `outlook_web/db.py`
## 测试前置条件
1. 本地启动项目:
```bash
python start.py
```
2. 准备一个可用的 Outlook OAuth 授权流程。
3. 准备至少一个普通分组,不使用“临时邮箱”系统分组。
4. 登录系统后台。
## 人工测试用例
### RTP-001 注册按钮可见性
步骤:
1. 打开“账号管理”页面。
2. 选择任意非“临时邮箱”的分组。
3. 观察账号面板右上角是否显示“`🔑 注册`”按钮。
预期结果:
1. 普通分组显示“`🔑 注册`”按钮。
2. 选择“临时邮箱”分组时该按钮隐藏。
### RTP-002 OAuth 获取 Token
步骤:
1. 点击“`🔑 注册`”按钮。
2. 获取授权链接并完成微软授权。
3. 将回调完整 URL 粘贴回弹窗。
4. 点击“换取 Token”。
预期结果:
1. 能成功换取 `refresh_token`。
2. 页面自动回到“导入账号”弹窗。
3. 输入框被预填为 `邮箱----密码----client_id----refresh_token` 格式。
### RTP-003 注册 Outlook 账号
步骤:
1. 将预填内容中的占位邮箱、密码替换为真实值。
2. 点击导入。
3. 等待导入成功提示。
4. 在当前分组列表中确认账号出现。
预期结果:
1. 页面提示导入成功。
2. 分组账号列表可见该 Outlook 账号。
### RTP-004 注册后是否进入 Pool
步骤:
1. 完成 RTP-003。
2. 打开数据库,执行以下 SQL:
```sql
SELECT email, pool_status, provider, account_type
FROM accounts
WHERE email = '刚注册的邮箱';
```
预期结果:
1. 如果勾选“导入后加入邮箱池”,这里应返回 `pool_status = 'available'`。
2. 如果未勾选,这里应返回 `pool_status = NULL`。
### RTP-005 内部池领取验证
步骤:
1. 在系统已登录状态下调用:
```bash
curl -X POST http://127.0.0.1:5000/api/pool/claim-random ^
-H "Content-Type: application/json" ^
-d "{\"caller_id\":\"manual_test\",\"task_id\":\"rtp005\"}"
```
2. 观察返回结果。
预期结果:
1. 如果注册账号已进池,应返回 `success=true`,并包含该账号的 `account_id`、`email`、`claim_token`。
当前验证重点:
1. 勾选加入池时,应能领取到刚注册的账号或至少使池内 `available` 数量增加。
2. 不勾选时,即使注册成功,也不应被池领取到。
### RTP-006 对外池领取验证
步骤:
1. 在设置中开启 `pool_external_enabled`。
2. 准备一个具备 `pool_access=true` 的 API Key。
3. 调用:
```bash
curl -X POST http://127.0.0.1:5000/api/external/pool/claim-random ^
-H "Content-Type: application/json" ^
-H "X-API-Key: <your-key>" ^
-d "{\"caller_id\":\"manual_test\",\"task_id\":\"rtp006\"}"
```
预期结果:
1. 如果注册账号已进池,应能正常领取。
2. 如果注册账号未进池,则依旧可能返回无可用账号。
### RTP-007 领取后状态流转
步骤:
1. 先确保池内存在一个 `available` 账号。
2. 调用 `/api/pool/claim-random` 领取。
3. 调用 `/api/pool/claim-complete`,分别传入:
- `success`
- `verification_timeout`
- `provider_blocked`
- `credential_invalid`
- `network_error`
预期结果:
1. 状态分别流转到:
- `used`
- `cooldown`
- `frozen`
- `retired`
- `available`
## 已确认的自动化基线
已执行:
```bash
python -m unittest tests.test_pool -v
```
结果:
1. `39` 个池相关用例全部通过。
2. 说明“池本身的 claim/release/complete/stats 逻辑”当前没有明显回归。
3. 但这些测试主要覆盖“已有池内账号”的流转,不覆盖“注册后进入池”。
## 自动化补充
本轮已补充 API 级回归思路:
1. 默认导入不入池。
2. 勾选 `add_to_pool=true` 时进入 `available`。
3. `provider=auto` 的 Outlook/IMAP 导入同样支持该开关。
+28 -21
View File
@@ -30,12 +30,8 @@ class ExternalPoolApiTests(unittest.TestCase):
settings_repo.set_setting("pool_external_enabled", "false")
settings_repo.set_setting("external_api_ip_whitelist", "[]")
settings_repo.set_setting("external_api_disable_pool_claim_random", "false")
settings_repo.set_setting(
"external_api_disable_pool_claim_release", "false"
)
settings_repo.set_setting(
"external_api_disable_pool_claim_complete", "false"
)
settings_repo.set_setting("external_api_disable_pool_claim_release", "false")
settings_repo.set_setting("external_api_disable_pool_claim_complete", "false")
settings_repo.set_setting("external_api_disable_pool_stats", "false")
@staticmethod
@@ -57,9 +53,7 @@ class ExternalPoolApiTests(unittest.TestCase):
enabled: bool = True,
):
with self.app.app_context():
from outlook_web.repositories import (
external_api_keys as external_api_keys_repo,
)
from outlook_web.repositories import external_api_keys as external_api_keys_repo
return external_api_keys_repo.create_external_api_key(
name=name,
@@ -73,9 +67,7 @@ class ExternalPoolApiTests(unittest.TestCase):
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"
)
settings_repo.set_setting("external_api_public_mode", "true" if enabled else "false")
def _set_ip_whitelist(self, ips: list[str]):
import json
@@ -91,9 +83,7 @@ class ExternalPoolApiTests(unittest.TestCase):
settings_repo.set_setting(setting_key, "true" if enabled else "false")
def _insert_pool_account(
self, *, provider: str = "outlook", pool_status: str = "available"
) -> int:
def _insert_pool_account(self, *, provider: str = "outlook", pool_status: str = "available") -> int:
email_addr = f"{uuid.uuid4().hex}@extpool.test"
with self.app.app_context():
from outlook_web.db import get_db
@@ -119,9 +109,7 @@ class ExternalPoolApiTests(unittest.TestCase):
),
)
db.commit()
row = db.execute(
"SELECT id FROM accounts WHERE email = ?", (email_addr,)
).fetchone()
row = db.execute("SELECT id FROM accounts WHERE email = ?", (email_addr,)).fetchone()
return int(row["id"])
def test_external_pool_stats_requires_api_key(self):
@@ -185,6 +173,27 @@ class ExternalPoolApiTests(unittest.TestCase):
self.assertTrue(data.get("success"))
self.assertEqual(data.get("code"), "OK")
def test_external_pool_blueprint_applies_csrf_exempt_to_all_handlers(self):
from outlook_web.routes import external_pool as external_pool_routes
wrapped_handlers = []
def fake_csrf_exempt(handler):
wrapped_handlers.append(handler.__name__)
return handler
external_pool_routes.create_blueprint(csrf_exempt=fake_csrf_exempt)
self.assertEqual(
set(wrapped_handlers),
{
"api_external_pool_claim_random",
"api_external_pool_claim_release",
"api_external_pool_claim_complete",
"api_external_pool_stats",
},
)
def test_external_pool_claim_release_caller_mismatch(self):
client = self.app.test_client()
self._set_external_api_key("abc123")
@@ -436,9 +445,7 @@ class ExternalPoolApiTests(unittest.TestCase):
def test_external_pool_claim_random_requires_pool_access_for_multi_key(self):
client = self.app.test_client()
self._create_external_api_key(
"partner-a", "multi-pool-random-deny", pool_access=False
)
self._create_external_api_key("partner-a", "multi-pool-random-deny", pool_access=False)
with self.app.app_context():
from outlook_web.repositories import settings as settings_repo
+14 -38
View File
@@ -32,12 +32,8 @@ class ExternalPoolE2ETests(unittest.TestCase):
settings_repo.set_setting("external_api_ip_whitelist", "[]")
settings_repo.set_setting("pool_external_enabled", "true")
settings_repo.set_setting("external_api_disable_pool_claim_random", "false")
settings_repo.set_setting(
"external_api_disable_pool_claim_release", "false"
)
settings_repo.set_setting(
"external_api_disable_pool_claim_complete", "false"
)
settings_repo.set_setting("external_api_disable_pool_claim_release", "false")
settings_repo.set_setting("external_api_disable_pool_claim_complete", "false")
settings_repo.set_setting("external_api_disable_pool_stats", "false")
@staticmethod
@@ -46,9 +42,7 @@ class ExternalPoolE2ETests(unittest.TestCase):
def _create_external_api_key(self, name: str, api_key: str, *, pool_access: bool):
with self.app.app_context():
from outlook_web.repositories import (
external_api_keys as external_api_keys_repo,
)
from outlook_web.repositories import external_api_keys as external_api_keys_repo
return external_api_keys_repo.create_external_api_key(
name=name,
@@ -84,9 +78,7 @@ class ExternalPoolE2ETests(unittest.TestCase):
),
)
db.commit()
row = db.execute(
"SELECT id FROM accounts WHERE email = ?", (email_addr,)
).fetchone()
row = db.execute("SELECT id FROM accounts WHERE email = ?", (email_addr,)).fetchone()
return int(row["id"])
def _external_audit_logs(self):
@@ -94,14 +86,12 @@ class ExternalPoolE2ETests(unittest.TestCase):
from outlook_web.db import get_db
db = get_db()
rows = db.execute(
"""
rows = db.execute("""
SELECT action, resource_id, details
FROM audit_logs
WHERE resource_type = 'external_api'
ORDER BY id ASC
"""
).fetchall()
""").fetchall()
return [dict(row) for row in rows]
def _external_consumer_usage_rows(self):
@@ -109,13 +99,11 @@ class ExternalPoolE2ETests(unittest.TestCase):
from outlook_web.db import get_db
db = get_db()
rows = db.execute(
"""
rows = db.execute("""
SELECT consumer_key, consumer_name, endpoint, total_count, success_count, error_count
FROM external_api_consumer_usage_daily
ORDER BY id ASC
"""
).fetchall()
""").fetchall()
return [dict(row) for row in rows]
def test_external_pool_e2e_claim_complete_records_audit_and_usage(self):
@@ -165,26 +153,18 @@ class ExternalPoolE2ETests(unittest.TestCase):
self.assertIn("/api/external/pool/claim-random", endpoints)
self.assertIn("/api/external/pool/claim-complete", endpoints)
self.assertIn("/api/external/pool/stats", endpoints)
self.assertTrue(
all(item.get("consumer_name") == "partner-e2e" for item in parsed_details)
)
self.assertTrue(all(item.get("consumer_name") == "partner-e2e" for item in parsed_details))
usage_rows = self._external_consumer_usage_rows()
self.assertEqual(len(usage_rows), 3)
by_endpoint = {row["endpoint"]: row for row in usage_rows}
self.assertEqual(
by_endpoint["/api/external/pool/claim-random"]["success_count"], 1
)
self.assertEqual(
by_endpoint["/api/external/pool/claim-complete"]["success_count"], 1
)
self.assertEqual(by_endpoint["/api/external/pool/claim-random"]["success_count"], 1)
self.assertEqual(by_endpoint["/api/external/pool/claim-complete"]["success_count"], 1)
self.assertEqual(by_endpoint["/api/external/pool/stats"]["success_count"], 1)
def test_external_pool_e2e_public_mode_disable_and_pool_access_chain(self):
client = self.app.test_client()
self._create_external_api_key(
"partner-deny", "pool-e2e-deny", pool_access=False
)
self._create_external_api_key("partner-deny", "pool-e2e-deny", pool_access=False)
self._insert_pool_account(provider="outlook")
deny_resp = client.post(
@@ -203,14 +183,10 @@ class ExternalPoolE2ETests(unittest.TestCase):
from outlook_web.repositories import settings as settings_repo
settings_repo.set_setting("external_api_public_mode", "true")
settings_repo.set_setting(
"external_api_ip_whitelist", json.dumps(["127.0.0.1"])
)
settings_repo.set_setting("external_api_ip_whitelist", json.dumps(["127.0.0.1"]))
settings_repo.set_setting("external_api_disable_pool_claim_random", "true")
allow_key = self._create_external_api_key(
"partner-allow", "pool-e2e-allow", pool_access=True
)
allow_key = self._create_external_api_key("partner-allow", "pool-e2e-allow", pool_access=True)
self.assertIsNotNone(allow_key)
feature_disabled_resp = client.post(
+50 -71
View File
@@ -8,8 +8,8 @@ class PoolRepositoryTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.module = import_web_app_module()
from outlook_web.repositories import pool as pool_repo
from outlook_web.db import create_sqlite_connection
from outlook_web.repositories import pool as pool_repo
cls.pool_repo = pool_repo
cls.create_conn = staticmethod(lambda: create_sqlite_connection())
@@ -26,18 +26,14 @@ class PoolRepositoryTests(unittest.TestCase):
(email, pool_status),
)
conn.commit()
row = conn.execute(
"SELECT id FROM accounts WHERE email = ?", (email,)
).fetchone()
row = conn.execute("SELECT id FROM accounts WHERE email = ?", (email,)).fetchone()
return row["id"]
def test_claim_and_complete_success(self):
conn = self.create_conn()
try:
self._make_account(conn)
result = self.pool_repo.claim_atomic(
conn, caller_id="reg_bot", task_id="task_001", lease_seconds=60
)
result = self.pool_repo.claim_atomic(conn, caller_id="reg_bot", task_id="task_001", lease_seconds=60)
self.assertIsNotNone(result)
self.assertTrue(result["claim_token"].startswith("clm_"))
claimed_id = result["id"]
@@ -72,9 +68,7 @@ class PoolRepositoryTests(unittest.TestCase):
conn = self.create_conn()
try:
account_id = self._make_account(conn)
result = self.pool_repo.claim_atomic(
conn, caller_id="reg_bot", task_id="task_002", lease_seconds=60
)
result = self.pool_repo.claim_atomic(conn, caller_id="reg_bot", task_id="task_002", lease_seconds=60)
self.assertIsNotNone(result)
self.pool_repo.release(
@@ -99,15 +93,11 @@ class PoolRepositoryTests(unittest.TestCase):
conn = self.create_conn()
try:
self._make_account(conn)
result = self.pool_repo.claim_atomic(
conn, caller_id="reg_bot", task_id="task_003", lease_seconds=60
)
result = self.pool_repo.claim_atomic(conn, caller_id="reg_bot", task_id="task_003", lease_seconds=60)
self.assertIsNotNone(result)
claimed_id = result["id"]
before = conn.execute(
"SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)
).fetchone()["fail_count"]
before = conn.execute("SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)).fetchone()["fail_count"]
new_status = self.pool_repo.complete(
conn,
@@ -164,9 +154,7 @@ class PoolRepositoryTests(unittest.TestCase):
expired_count = self.pool_repo.expire_stale_claims(conn)
self.assertGreaterEqual(expired_count, 1)
row = conn.execute(
"SELECT pool_status FROM accounts WHERE id = ?", (account_id,)
).fetchone()
row = conn.execute("SELECT pool_status FROM accounts WHERE id = ?", (account_id,)).fetchone()
self.assertEqual(row["pool_status"], "cooldown")
finally:
conn.close()
@@ -184,9 +172,7 @@ class PoolRepositoryTests(unittest.TestCase):
recovered = self.pool_repo.recover_cooldown(conn, cooldown_seconds=3600)
self.assertGreaterEqual(recovered, 1)
row = conn.execute(
"SELECT pool_status FROM accounts WHERE id = ?", (account_id,)
).fetchone()
row = conn.execute("SELECT pool_status FROM accounts WHERE id = ?", (account_id,)).fetchone()
self.assertEqual(row["pool_status"], "available")
finally:
conn.close()
@@ -203,19 +189,40 @@ class PoolRepositoryTests(unittest.TestCase):
finally:
conn.close()
def test_get_stats_ignores_accounts_without_pool_status(self):
conn = self.create_conn()
try:
import secrets
email = f"pool_null_status_{secrets.token_hex(4)}@example.com"
conn.execute(
"""
INSERT INTO accounts (email, client_id, refresh_token, status, pool_status)
VALUES (?, 'test_client', 'test_token', 'active', NULL)
""",
(email,),
)
conn.commit()
stats = self.pool_repo.get_stats(conn)
self.assertEqual(
set(stats["pool_counts"].keys()),
{"available", "claimed", "used", "cooldown", "frozen", "retired"},
)
self.assertNotIn("not_in_pool", stats["pool_counts"])
finally:
conn.close()
def test_complete_network_error_returns_available_and_increments_fail_count(self):
conn = self.create_conn()
try:
account_id = self._make_account(conn)
result = self.pool_repo.claim_atomic(
conn, caller_id="reg_bot", task_id="task_ne", lease_seconds=60
)
result = self.pool_repo.claim_atomic(conn, caller_id="reg_bot", task_id="task_ne", lease_seconds=60)
self.assertIsNotNone(result)
claimed_id = result["id"]
before = conn.execute(
"SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)
).fetchone()["fail_count"]
before = conn.execute("SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)).fetchone()["fail_count"]
new_status = self.pool_repo.complete(
conn,
@@ -241,15 +248,11 @@ class PoolRepositoryTests(unittest.TestCase):
conn = self.create_conn()
try:
account_id = self._make_account(conn)
result = self.pool_repo.claim_atomic(
conn, caller_id="reg_bot", task_id="task_pb", lease_seconds=60
)
result = self.pool_repo.claim_atomic(conn, caller_id="reg_bot", task_id="task_pb", lease_seconds=60)
self.assertIsNotNone(result)
claimed_id = result["id"]
before = conn.execute(
"SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)
).fetchone()["fail_count"]
before = conn.execute("SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)).fetchone()["fail_count"]
new_status = self.pool_repo.complete(
conn,
@@ -275,15 +278,11 @@ class PoolRepositoryTests(unittest.TestCase):
conn = self.create_conn()
try:
account_id = self._make_account(conn)
result = self.pool_repo.claim_atomic(
conn, caller_id="reg_bot", task_id="task_ci", lease_seconds=60
)
result = self.pool_repo.claim_atomic(conn, caller_id="reg_bot", task_id="task_ci", lease_seconds=60)
self.assertIsNotNone(result)
claimed_id = result["id"]
before = conn.execute(
"SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)
).fetchone()["fail_count"]
before = conn.execute("SELECT fail_count FROM accounts WHERE id = ?", (claimed_id,)).fetchone()["fail_count"]
new_status = self.pool_repo.complete(
conn,
@@ -309,9 +308,7 @@ class PoolRepositoryTests(unittest.TestCase):
conn = self.create_conn()
try:
account_id = self._make_account(conn)
result = self.pool_repo.claim_atomic(
conn, caller_id="log_bot", task_id="log_task_001", lease_seconds=60
)
result = self.pool_repo.claim_atomic(conn, caller_id="log_bot", task_id="log_task_001", lease_seconds=60)
self.assertIsNotNone(result)
claimed_id = result["id"]
log_row = conn.execute(
@@ -334,9 +331,7 @@ class PoolRepositoryTests(unittest.TestCase):
conn = self.create_conn()
try:
account_id = self._make_account(conn)
result = self.pool_repo.claim_atomic(
conn, caller_id="log_bot", task_id="log_task_002", lease_seconds=60
)
result = self.pool_repo.claim_atomic(conn, caller_id="log_bot", task_id="log_task_002", lease_seconds=60)
self.assertIsNotNone(result)
claimed_id = result["id"]
self.pool_repo.complete(
@@ -397,9 +392,7 @@ class PoolRepositoryTests(unittest.TestCase):
conn = self.create_conn()
try:
account_id = self._make_account(conn)
result1 = self.pool_repo.claim_atomic(
conn, caller_id="bot", task_id="excl_t1", lease_seconds=60
)
result1 = self.pool_repo.claim_atomic(conn, caller_id="bot", task_id="excl_t1", lease_seconds=60)
self.assertIsNotNone(result1)
claimed_id = result1["id"]
self.pool_repo.release(
@@ -434,9 +427,7 @@ class PoolRepositoryTests(unittest.TestCase):
tokens = set()
for i in range(5):
account_id = self._make_account(conn)
result = self.pool_repo.claim_atomic(
conn, caller_id="bot", task_id=f"uniq_t{i}", lease_seconds=60
)
result = self.pool_repo.claim_atomic(conn, caller_id="bot", task_id=f"uniq_t{i}", lease_seconds=60)
self.assertIsNotNone(result)
tokens.add(result["claim_token"])
self.pool_repo.complete(
@@ -494,9 +485,7 @@ class PoolRepositoryTests(unittest.TestCase):
conn = self.create_conn()
try:
account_id = self._make_account(conn)
result = self.pool_repo.claim_atomic(
conn, caller_id="log_bot", task_id="log_rel_001", lease_seconds=60
)
result = self.pool_repo.claim_atomic(conn, caller_id="log_bot", task_id="log_rel_001", lease_seconds=60)
self.assertIsNotNone(result)
claimed_id = result["id"]
@@ -593,12 +582,8 @@ class PoolRepositoryTests(unittest.TestCase):
claimed_after = stats_after["pool_counts"]["claimed"]
cooldown_after = stats_after["pool_counts"]["cooldown"]
self.assertLess(
claimed_after, claimed_before, "expire 后 claimed 数量应减少"
)
self.assertGreater(
cooldown_after, cooldown_before, "expire 后 cooldown 数量应增加"
)
self.assertLess(claimed_after, claimed_before, "expire 后 claimed 数量应减少")
self.assertGreater(cooldown_after, cooldown_before, "expire 后 cooldown 数量应增加")
finally:
conn.close()
@@ -607,8 +592,8 @@ class PoolServiceTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.module = import_web_app_module()
from outlook_web.services import pool as pool_service
from outlook_web.db import create_sqlite_connection
from outlook_web.services import pool as pool_service
cls.pool_service = pool_service
cls.create_conn = staticmethod(lambda: create_sqlite_connection())
@@ -627,9 +612,7 @@ class PoolServiceTests(unittest.TestCase):
(email, pool_status),
)
conn.commit()
row = conn.execute(
"SELECT id FROM accounts WHERE email = ?", (email,)
).fetchone()
row = conn.execute("SELECT id FROM accounts WHERE email = ?", (email,)).fetchone()
return row["id"]
finally:
conn.close()
@@ -830,9 +813,7 @@ class PoolApiTests(unittest.TestCase):
(email,),
)
conn.commit()
row = conn.execute(
"SELECT id FROM accounts WHERE email = ?", (email,)
).fetchone()
row = conn.execute("SELECT id FROM accounts WHERE email = ?", (email,)).fetchone()
return row["id"]
finally:
conn.close()
@@ -897,9 +878,7 @@ class PoolApiTests(unittest.TestCase):
self.assertEqual(complete_resp.status_code, 200)
complete_data = json.loads(complete_resp.data)
self.assertTrue(complete_data["success"])
self.assertEqual(
complete_data["data"]["account_id"], account_data["account_id"]
)
self.assertEqual(complete_data["data"]["account_id"], account_data["account_id"])
self.assertEqual(complete_data["data"]["pool_status"], "used")
def test_claim_release_flow(self):
+185
View File
@@ -0,0 +1,185 @@
import json
import unittest
import uuid
from tests._import_app import import_web_app_module
class PoolFlowSuiteTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.module = import_web_app_module()
cls.app = cls.module.app
cls.client = cls.app.test_client()
from outlook_web.db import create_sqlite_connection
cls.create_conn = staticmethod(lambda: create_sqlite_connection())
def _make_pool_account(self, *, provider: str = "outlook", pool_status: str = "available") -> dict:
conn = self.create_conn()
try:
email_addr = f"flow_{uuid.uuid4().hex}@poolflow.test"
conn.execute(
"""
INSERT INTO accounts (
email, client_id, refresh_token, status,
account_type, provider, group_id, pool_status
)
VALUES (?, 'test_client', 'test_token', 'active', 'outlook', ?, 1, ?)
""",
(email_addr, provider, pool_status),
)
conn.commit()
row = conn.execute(
"SELECT id, email, pool_status, provider FROM accounts WHERE email = ?",
(email_addr,),
).fetchone()
return dict(row)
finally:
conn.close()
def test_claim_complete_success_changes_status_to_used(self):
self._make_pool_account()
claim_resp = self.client.post(
"/api/pool/claim-random",
json={"caller_id": "suite_bot", "task_id": "success_flow"},
)
self.assertEqual(claim_resp.status_code, 200)
claim_data = json.loads(claim_resp.data)
self.assertTrue(claim_data["success"])
complete_resp = self.client.post(
"/api/pool/claim-complete",
json={
"account_id": claim_data["data"]["account_id"],
"claim_token": claim_data["data"]["claim_token"],
"caller_id": "suite_bot",
"task_id": "success_flow",
"result": "success",
"detail": "manual suite success",
},
)
self.assertEqual(complete_resp.status_code, 200)
complete_data = json.loads(complete_resp.data)
self.assertTrue(complete_data["success"])
self.assertEqual(complete_data["data"]["pool_status"], "used")
conn = self.create_conn()
try:
row = conn.execute(
"SELECT pool_status, success_count, fail_count FROM accounts WHERE id = ?",
(claim_data["data"]["account_id"],),
).fetchone()
self.assertEqual(row["pool_status"], "used")
self.assertEqual(row["success_count"], 1)
self.assertEqual(row["fail_count"], 0)
finally:
conn.close()
def test_claim_complete_failure_changes_status_to_cooldown(self):
self._make_pool_account()
claim_resp = self.client.post(
"/api/pool/claim-random",
json={"caller_id": "suite_bot", "task_id": "cooldown_flow"},
)
self.assertEqual(claim_resp.status_code, 200)
claim_data = json.loads(claim_resp.data)
self.assertTrue(claim_data["success"])
complete_resp = self.client.post(
"/api/pool/claim-complete",
json={
"account_id": claim_data["data"]["account_id"],
"claim_token": claim_data["data"]["claim_token"],
"caller_id": "suite_bot",
"task_id": "cooldown_flow",
"result": "verification_timeout",
"detail": "manual suite timeout",
},
)
self.assertEqual(complete_resp.status_code, 200)
complete_data = json.loads(complete_resp.data)
self.assertTrue(complete_data["success"])
self.assertEqual(complete_data["data"]["pool_status"], "cooldown")
conn = self.create_conn()
try:
row = conn.execute(
"SELECT pool_status, success_count, fail_count, last_result FROM accounts WHERE id = ?",
(claim_data["data"]["account_id"],),
).fetchone()
self.assertEqual(row["pool_status"], "cooldown")
self.assertEqual(row["success_count"], 0)
self.assertEqual(row["fail_count"], 1)
self.assertEqual(row["last_result"], "verification_timeout")
finally:
conn.close()
def test_multiple_consecutive_claims_do_not_repeat_accounts(self):
provider = f"suiteprov_{uuid.uuid4().hex}"
created_ids = []
for _ in range(3):
created = self._make_pool_account(provider=provider)
created_ids.append(created["id"])
claimed_ids = []
claimed_tokens = []
for idx in range(3):
resp = self.client.post(
"/api/pool/claim-random",
json={
"caller_id": "suite_bot",
"task_id": f"batch_claim_{idx}",
"provider": provider,
},
)
self.assertEqual(resp.status_code, 200)
data = json.loads(resp.data)
self.assertTrue(data["success"])
claimed_ids.append(data["data"]["account_id"])
claimed_tokens.append(
(
data["data"]["account_id"],
data["data"]["claim_token"],
f"batch_claim_{idx}",
)
)
self.assertEqual(len(claimed_ids), len(set(claimed_ids)))
self.assertTrue(set(claimed_ids).issubset(set(created_ids)))
for account_id, claim_token, task_id in claimed_tokens:
release_resp = self.client.post(
"/api/pool/claim-release",
json={
"account_id": account_id,
"claim_token": claim_token,
"caller_id": "suite_bot",
"task_id": task_id,
"reason": "suite cleanup",
},
)
self.assertEqual(release_resp.status_code, 200)
release_data = json.loads(release_resp.data)
self.assertTrue(release_data["success"])
self.assertEqual(release_data["data"]["pool_status"], "available")
conn = self.create_conn()
try:
placeholders = ",".join(["?"] * len(created_ids))
rows = conn.execute(
f"SELECT id, pool_status, claim_token FROM accounts WHERE id IN ({placeholders})",
created_ids,
).fetchall()
self.assertEqual(len(rows), 3)
for row in rows:
self.assertEqual(row["pool_status"], "available")
self.assertIsNone(row["claim_token"])
finally:
conn.close()
if __name__ == "__main__":
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
import unittest
import uuid
from tests._import_app import clear_login_attempts, import_web_app_module
class PoolRegistrationTests(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()
def _login(self, client):
resp = client.post("/login", json={"password": "testpass123"})
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.get_json().get("success"))
def _account_row(self, email_addr: str):
conn = self.module.create_sqlite_connection()
try:
return conn.execute(
"SELECT email, pool_status, provider, account_type FROM accounts WHERE email = ? LIMIT 1",
(email_addr,),
).fetchone()
finally:
conn.close()
def test_standard_import_keeps_account_outside_pool_by_default(self):
client = self.app.test_client()
self._login(client)
email_addr = f"default_pool_{uuid.uuid4().hex}@poolreg.test"
resp = client.post(
"/api/accounts",
json={
"account_string": f"{email_addr}----pwd----cid----rtoken",
"group_id": 1,
},
)
self.assertEqual(resp.status_code, 200)
data = resp.get_json()
self.assertTrue(data.get("success"))
row = self._account_row(email_addr)
self.assertIsNotNone(row)
self.assertIsNone(row["pool_status"])
def test_standard_import_can_explicitly_join_pool(self):
client = self.app.test_client()
self._login(client)
email_addr = f"join_pool_{uuid.uuid4().hex}@poolreg.test"
resp = client.post(
"/api/accounts",
json={
"account_string": f"{email_addr}----pwd----cid----rtoken",
"group_id": 1,
"add_to_pool": True,
},
)
self.assertEqual(resp.status_code, 200)
data = resp.get_json()
self.assertTrue(data.get("success"))
row = self._account_row(email_addr)
self.assertIsNotNone(row)
self.assertEqual(row["pool_status"], "available")
self.assertEqual(row["provider"], "outlook")
self.assertEqual(row["account_type"], "outlook")
def test_auto_import_can_explicitly_join_pool(self):
client = self.app.test_client()
self._login(client)
email_addr = f"auto_join_{uuid.uuid4().hex}@gmail.com"
resp = client.post(
"/api/accounts",
json={
"provider": "auto",
"group_id": None,
"duplicate_strategy": "skip",
"add_to_pool": True,
"account_string": f"{email_addr}----imap-pass",
},
)
self.assertEqual(resp.status_code, 200)
data = resp.get_json()
self.assertTrue(data.get("success"))
row = self._account_row(email_addr)
self.assertIsNotNone(row)
self.assertEqual(row["pool_status"], "available")
self.assertEqual(row["provider"], "gmail")
self.assertEqual(row["account_type"], "imap")
if __name__ == "__main__":
unittest.main()
+3 -9
View File
@@ -22,12 +22,8 @@ class ExternalApiKeySettingsTests(unittest.TestCase):
settings_repo.set_setting("external_api_key", "")
settings_repo.set_setting("pool_external_enabled", "false")
settings_repo.set_setting("external_api_disable_pool_claim_random", "false")
settings_repo.set_setting(
"external_api_disable_pool_claim_release", "false"
)
settings_repo.set_setting(
"external_api_disable_pool_claim_complete", "false"
)
settings_repo.set_setting("external_api_disable_pool_claim_release", "false")
settings_repo.set_setting("external_api_disable_pool_claim_complete", "false")
settings_repo.set_setting("external_api_disable_pool_stats", "false")
def _login(self, client, password: str = "testpass123"):
@@ -107,9 +103,7 @@ class ExternalApiKeySettingsTests(unittest.TestCase):
def test_get_settings_exposes_external_api_keys_list(self):
with self.app.app_context():
from outlook_web.repositories import (
external_api_keys as external_api_keys_repo,
)
from outlook_web.repositories import external_api_keys as external_api_keys_repo
external_api_keys_repo.create_external_api_key(
name="partner-a",
+462
View File
@@ -0,0 +1,462 @@
# 注册机接入与邮箱池使用说明
这份文档不是给开发框架人员看的,而是给注册机业务方、脚本接入方、流程运营方看的。
你们真正需要关心的只有 4 件事:
1. 账号怎么进入池子
2. 注册机怎么拿号
3. 注册完成后怎么回传结果
4. 不同结果会把账号变成什么状态
如果你只想先跑通,直接看「一页版接入流程」。
## 一页版接入流程
### 业务流程
1. 运营或后台人员先把邮箱账号导入系统
2. 导入时显式勾选或传入 `add_to_pool=true`
3. 注册机调用领取接口拿一个邮箱
4. 注册机拿到邮箱后去执行注册任务
5. 任务结束后必须回传结果
6. 系统按结果把邮箱改成 `used` / `cooldown` / `available` 等状态
### 你们只需要调用这 3 个接口
- `POST /api/pool/claim-random`
- `POST /api/pool/claim-complete`
- `POST /api/pool/claim-release`
`GET /api/pool/stats` 只是查池子情况,可选。
## 先说结论
### 什么样的账号能被注册机拿到
只有同时满足下面条件的账号才会被分配出来:
- 已经导入系统
- 导入时加入了邮箱池
- 当前池状态是 `available`
- 当前账号状态是 `active`
### 注册机拿到邮箱后必须做什么
拿到邮箱后,任务结束时必须二选一:
- 成功或失败后,调用 `claim-complete`
- 中途放弃不用了,调用 `claim-release`
不能拿完不回传。
### 最重要的状态流转
| 任务结果 | 你们回传的 `result` | 邮箱最终状态 |
|---|---|---|
| 注册成功,账号已消耗 | `success` | `used` |
| 长时间没等到验证码,建议冷却后再试 | `verification_timeout` | `cooldown` |
| 供应商风控、被封、被限制 | `provider_blocked` | `frozen` |
| 邮箱密码或凭据已失效 | `credential_invalid` | `retired` |
| 网络问题、临时故障,可以马上重试 | `network_error` | `available` |
这张表就是注册机接入时最关键的业务规则。
## 角色分工
### 后台人员负责
- 导入邮箱账号
- 决定是否把账号加入池子
- 查看池子数量和状态
### 注册机负责
- 领取邮箱
- 用领取到的邮箱执行注册
- 把最终结果准确回传
### 你们不需要关心的内容
- Outlook OAuth 内部换 token 细节
- 后台导入时的内部校验逻辑
- 数据库存储方式
- 内部控制器和仓库层实现
## 账号如何进入邮箱池
账号不是导入后自动进池。
必须在导入账号时明确指定 `add_to_pool=true`,这个账号才会进入池子,初始状态才会是 `available`。
如果没有加池:
- 账号只是存在系统里
- 注册机永远领不到它
这点是整个流程里最容易出错的地方。
## 注册机标准接入流程
## 第一步:领取一个邮箱
接口:
```text
POST /api/pool/claim-random
```
请求示例:
```json
{
"caller_id": "register-worker-1",
"task_id": "job-20260317-0001",
"provider": "outlook"
}
```
字段说明:
- `caller_id`:谁在调用,建议写固定的业务方标识或机器标识
- `task_id`:当前这次注册任务的唯一 ID
- `provider`:可选。如果你们只想领 Outlook,就传 `outlook`
成功返回示例:
```json
{
"success": true,
"data": {
"account_id": 12,
"email": "demo@outlook.com",
"claim_token": "clm_xxxxx",
"lease_expires_at": "2026-03-17T10:00:00Z"
}
}
```
你们拿到后要保存这 4 个值:
- `account_id`
- `email`
- `claim_token`
- `lease_expires_at`
其中真正必须原样带回来的,是:
- `account_id`
- `claim_token`
- `caller_id`
- `task_id`
### 领取失败是什么样
```json
{
"success": false,
"error": "no_available_account",
"message": "池中没有符合条件的可用邮箱"
}
```
这表示当前没有可分配邮箱,不是系统崩了。
注册机这时应该:
- 等待
- 稍后重试
- 或切换 provider
## 第二步:执行注册任务
这一步是你们自己的业务逻辑,系统不关心你们怎么注册,只关心最终结果。
常见动作包括:
- 打开目标网站
- 输入邮箱
- 收验证码
- 验证邮箱
- 完成注册
## 第三步:回传结果
任务做完以后,必须回传。
接口:
```text
POST /api/pool/claim-complete
```
请求示例:
```json
{
"account_id": 12,
"claim_token": "clm_xxxxx",
"caller_id": "register-worker-1",
"task_id": "job-20260317-0001",
"result": "success",
"detail": "注册成功"
}
```
成功返回示例:
```json
{
"success": true,
"data": {
"account_id": 12,
"pool_status": "used"
}
}
```
这里返回的 `pool_status` 就是这个邮箱的最终状态。
## 中途放弃时怎么处理
如果你们已经领到了号,但这次任务不做了,不想把它记成成功或失败,就不要调 `claim-complete`,而是调释放接口。
接口:
```text
POST /api/pool/claim-release
```
请求示例:
```json
{
"account_id": 12,
"claim_token": "clm_xxxxx",
"caller_id": "register-worker-1",
"task_id": "job-20260317-0001",
"reason": "任务取消"
}
```
成功返回示例:
```json
{
"success": true,
"data": {
"account_id": 12,
"pool_status": "available"
}
}
```
这表示该邮箱被放回池子,可以再次分配。
## 最推荐的业务回传规则
为了避免业务方乱传结果,建议你们内部直接按下面这套规则接:
### 场景 1:注册成功
回传:
```json
{
"result": "success"
}
```
结果:
- 邮箱状态变 `used`
- 默认理解为这个号已经消耗掉
### 场景 2:一直收不到验证码
回传:
```json
{
"result": "verification_timeout"
}
```
结果:
- 邮箱状态变 `cooldown`
- 表示不要立刻继续发给下一个任务
### 场景 3:网站风控、邮箱域名被卡、供应商限制
回传:
```json
{
"result": "provider_blocked"
}
```
结果:
- 邮箱状态变 `frozen`
### 场景 4:密码失效、refresh token 失效、邮箱已经不可登录
回传:
```json
{
"result": "credential_invalid"
}
```
结果:
- 邮箱状态变 `retired`
### 场景 5:代理挂了、网络抖动、请求失败,但邮箱本身没问题
回传:
```json
{
"result": "network_error"
}
```
结果:
- 邮箱状态回到 `available`
- 后续可以再次分配
## 业务方最容易踩的坑
### 坑 1:账号导入了,但注册机领不到
一般原因只有两个:
- 导入时没有 `add_to_pool=true`
- 账号不是 `active` 或不在 `available`
### 坑 2:重复回传或回传错任务
`claim-complete` 和 `claim-release` 都要求下面 4 个字段对应同一笔领取记录:
- `account_id`
- `claim_token`
- `caller_id`
- `task_id`
任何一个不一致,系统都会拒绝。
所以注册机必须把领取时的原始参数保存好,不能自己拼。
### 坑 3:拿了号但不回传
这会导致邮箱停留在 `claimed`,影响后续分配。
你们内部必须保证:
- 成功就 `claim-complete`
- 失败也 `claim-complete`
- 放弃就 `claim-release`
### 坑 4:把所有失败都回成 `network_error`
这样会导致本来已经废掉的号继续回池,被重复分配。
特别是下面两种情况不能回 `network_error`:
- 邮箱凭据失效
- 明确被目标站或供应商风控
## 推荐接入规范
### `caller_id` 怎么写
建议固定,不要一会一个名字。
例如:
- `register-worker-1`
- `register-cluster-a`
- `tg-signup-service`
### `task_id` 怎么写
建议每次任务唯一。
例如:
- `job-20260317-0001`
- `order-928371`
- `signup-batch-7-item-23`
### `detail` 怎么写
不是必填,但建议写。
可以写:
- 失败原因
- 目标站名称
- 风控现象
- 错误摘要
这样后面排查方便。
## 你们真正要记住的 5 行规则
1. 只有 `add_to_pool=true` 的邮箱才会被注册机拿到
2. 注册机用 `claim-random` 拿号
3. 任务结束必须回传
4. 成功或失败用 `claim-complete`
5. 中途放弃用 `claim-release`
## 常用接口清单
### 1. 领取邮箱
```text
POST /api/pool/claim-random
```
### 2. 完成任务并回传结果
```text
POST /api/pool/claim-complete
```
### 3. 放弃任务并释放邮箱
```text
POST /api/pool/claim-release
```
### 4. 查看池子数量
```text
GET /api/pool/stats
```
## 补充说明
如果你们不是直接调用内网接口,而是走外部受控接口,那么路径会对应变成:
- `/api/external/pool/claim-random`
- `/api/external/pool/claim-complete`
- `/api/external/pool/claim-release`
- `/api/external/pool/stats`
但业务含义和回传规则是一致的。
## 最后一句
注册机业务方不需要理解系统内部实现,你们只要按这套规则接:
- 领号
- 干活
- 准确回传结果
这套流程就能跑通。