diff --git a/README.md b/README.md index f032e97..e0000dc 100644 --- a/README.md +++ b/README.md @@ -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/` / `...//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/` - -最小调用示例: - -```bash -curl -H "X-API-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 diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 5ac91d5..e942960 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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 diff --git a/outlook_web/__init__.py b/outlook_web/__init__.py index 40929da..597846d 100644 --- a/outlook_web/__init__.py +++ b/outlook_web/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.7.0" +__version__ = "1.8.0" from outlook_web.app import create_app diff --git a/outlook_web/app.py b/outlook_web/app.py index 777acad..542ab12 100644 --- a/outlook_web/app.py +++ b/outlook_web/app.py @@ -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 diff --git a/outlook_web/controllers/accounts.py b/outlook_web/controllers/accounts.py index c6bc6b2..057887f 100644 --- a/outlook_web/controllers/accounts.py +++ b/outlook_web/controllers/accounts.py @@ -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 diff --git a/outlook_web/controllers/external_pool.py b/outlook_web/controllers/external_pool.py index 7712401..845bbb8 100644 --- a/outlook_web/controllers/external_pool.py +++ b/outlook_web/controllers/external_pool.py @@ -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: diff --git a/outlook_web/controllers/pool.py b/outlook_web/controllers/pool.py index 5b022fa..620abd2 100644 --- a/outlook_web/controllers/pool.py +++ b/outlook_web/controllers/pool.py @@ -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(): diff --git a/outlook_web/controllers/settings.py b/outlook_web/controllers/settings.py index 1c9271c..32585e0 100644 --- a/outlook_web/controllers/settings.py +++ b/outlook_web/controllers/settings.py @@ -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"}) diff --git a/outlook_web/db.py b/outlook_web/db.py index ac2c87e..d096a00 100644 --- a/outlook_web/db.py +++ b/outlook_web/db.py @@ -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) diff --git a/outlook_web/repositories/accounts.py b/outlook_web/repositories/accounts.py index 91c5eae..65f9062 100644 --- a/outlook_web/repositories/accounts.py +++ b/outlook_web/repositories/accounts.py @@ -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: diff --git a/outlook_web/repositories/external_api_keys.py b/outlook_web/repositories/external_api_keys.py index d2a4260..fa03cad 100644 --- a/outlook_web/repositories/external_api_keys.py +++ b/outlook_web/repositories/external_api_keys.py @@ -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 {} diff --git a/outlook_web/repositories/pool.py b/outlook_web/repositories/pool.py index 52917c5..620db6e 100644 --- a/outlook_web/repositories/pool.py +++ b/outlook_web/repositories/pool.py @@ -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"] diff --git a/outlook_web/repositories/settings.py b/outlook_web/repositories/settings.py index 4984682..695cc9b 100644 --- a/outlook_web/repositories/settings.py +++ b/outlook_web/repositories/settings.py @@ -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: diff --git a/outlook_web/routes/external_pool.py b/outlook_web/routes/external_pool.py index 8b4bdb8..c196625 100644 --- a/outlook_web/routes/external_pool.py +++ b/outlook_web/routes/external_pool.py @@ -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) diff --git a/outlook_web/security/auth.py b/outlook_web/security/auth.py index 6916d3d..507d9ab 100644 --- a/outlook_web/security/auth.py +++ b/outlook_web/security/auth.py @@ -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, "验证已过期,请重新验证" diff --git a/outlook_web/services/pool.py b/outlook_web/services/pool.py index c320508..36b5eac 100644 --- a/outlook_web/services/pool.py +++ b/outlook_web/services/pool.py @@ -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() diff --git a/outlook_web/services/scheduler.py b/outlook_web/services/scheduler.py index cfba41f..1963188 100644 --- a/outlook_web/services/scheduler.py +++ b/outlook_web/services/scheduler.py @@ -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 diff --git a/static/js/features/accounts.js b/static/js/features/accounts.js index 73894e4..f375d30 100644 --- a/static/js/features/accounts.js +++ b/static/js/features/accounts.js @@ -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'; diff --git a/templates/partials/modals.html b/templates/partials/modals.html index 69ba183..d4c3aaa 100644 --- a/templates/partials/modals.html +++ b/templates/partials/modals.html @@ -115,6 +115,13 @@ placeholder="邮箱----密码----client_id----refresh_token">
格式:邮箱----密码----client_id----refresh_token,支持批量导入(每行一个)
+
+ +
勾选后,新导入的 Outlook/IMAP 账号会以 `available` 状态进入邮箱池;不勾选则保持池外。
+