From c36787288a7d4171b74c0a16615a12078a1facb5 Mon Sep 17 00:00:00 2001 From: CodeXWeb Date: Sun, 5 Jul 2026 15:37:32 +0000 Subject: [PATCH 1/2] add domain claim endpoint --- outlook_web/controllers/external_pool.py | 21 ++++++++-- outlook_web/routes/external_pool.py | 5 +++ registration-mail-pool-api.en.md | 41 +++++++++++++++++-- tests/test_pool_flow_suite.py | 33 +++++++++++++++ ...45\345\217\243\346\226\207\346\241\243.md" | 41 +++++++++++++++++-- 5 files changed, 129 insertions(+), 12 deletions(-) diff --git a/outlook_web/controllers/external_pool.py b/outlook_web/controllers/external_pool.py index f6b216c1..8bb07a0e 100644 --- a/outlook_web/controllers/external_pool.py +++ b/outlook_web/controllers/external_pool.py @@ -82,10 +82,7 @@ def _check_pool_access(endpoint: str): ) -@api_key_required -@external_api_guards(feature="pool_claim_random") -def api_external_pool_claim_random(): - endpoint = "/api/external/pool/claim-random" +def _claim_pool_account(endpoint: str, *, require_email_domain: bool = False): disabled_resp = _check_pool_external_enabled(endpoint) if disabled_resp is not None: return disabled_resp @@ -99,6 +96,10 @@ def api_external_pool_claim_random(): project_key = body.get("project_key") email_domain = body.get("email_domain") + if require_email_domain and not str(email_domain or "").strip(): + _audit(endpoint, "error", details={"code": "EMAIL_DOMAIN_REQUIRED"}) + return jsonify(external_api_service.fail("EMAIL_DOMAIN_REQUIRED", "email_domain 不能为空")), 400 + try: account = claim_random( caller_id=caller_id, @@ -137,6 +138,18 @@ def api_external_pool_claim_random(): return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 +@api_key_required +@external_api_guards(feature="pool_claim_random") +def api_external_pool_claim_random(): + return _claim_pool_account("/api/external/pool/claim-random") + + +@api_key_required +@external_api_guards(feature="pool_claim_random") +def api_external_pool_claim_domain(): + return _claim_pool_account("/api/external/pool/claim-domain", require_email_domain=True) + + @api_key_required @external_api_guards(feature="pool_claim_release") def api_external_pool_claim_release(): diff --git a/outlook_web/routes/external_pool.py b/outlook_web/routes/external_pool.py index c1966252..d395b762 100644 --- a/outlook_web/routes/external_pool.py +++ b/outlook_web/routes/external_pool.py @@ -20,6 +20,11 @@ def create_blueprint(csrf_exempt: Optional[Callable] = None) -> Blueprint: external_pool_controller.api_external_pool_claim_random, ["POST"], ), + ( + "/api/external/pool/claim-domain", + external_pool_controller.api_external_pool_claim_domain, + ["POST"], + ), ( "/api/external/pool/claim-release", external_pool_controller.api_external_pool_claim_release, diff --git a/registration-mail-pool-api.en.md b/registration-mail-pool-api.en.md index bc3c3006..76bad4de 100644 --- a/registration-mail-pool-api.en.md +++ b/registration-mail-pool-api.en.md @@ -104,7 +104,8 @@ Time fields use ISO 8601, for example: | Endpoint | Purpose | Recommended | | --- | --- | --- | -| `POST /api/external/pool/claim-random` | claim a mailbox | Common | +| `POST /api/external/pool/claim-random` | claim a mailbox, optionally filtered by domain | Common | +| `POST /api/external/pool/claim-domain` | claim a mailbox from a required domain | Common | | `POST /api/external/pool/claim-release` | release a mailbox | Common | | `POST /api/external/pool/claim-complete` | submit the task result | Common | | `GET /api/external/pool/stats` | inspect pool counts | Optional | @@ -324,12 +325,15 @@ Request body: | `caller_id` | string | Yes | caller instance, node, or worker identity | | `task_id` | string | Yes | unique task ID | | `provider` | string | No | provider filter: `outlook` / `imap` / `custom` / `cloudflare_temp_mail` | +| `project_key` | string | No | project-level reuse and duplicate-prevention context | +| `email_domain` | string | No | mailbox domain filter; when provided, only eligible mailboxes in that domain are claimed | Current implementation notes: -- the current pool API supports filtering only by `provider` -- `outlook.com`, `hotmail.com`, `live.com`, and `live.cn` all map to `provider=outlook` -- the current external pool API does not support extra filtering by domain, group, or tags +- `claim-random` claims an eligible mailbox randomly by default; when `email_domain` is provided, it claims randomly within that domain +- for a clearer domain-specific contract, use `POST /api/external/pool/claim-domain`; that endpoint requires `email_domain` +- `outlook.com`, `hotmail.com`, `live.com`, and `live.cn` all map to `provider=outlook`; use `email_domain` when those domains need to be distinguished +- the current external pool API does not support claiming a specific full mailbox, group, or tag - when `provider=cloudflare_temp_mail` and no eligible mailbox exists in pool, the service dynamically creates a CF temp mailbox and returns it as claimed Success response fields: @@ -391,6 +395,35 @@ No-available response example: } ``` +### `POST /api/external/pool/claim-domain` + +Purpose: claim a mailbox from a specific domain. This is the explicit endpoint for `claim-random + email_domain`; internally it reuses the same pool claim, lease, audit, and completion state machine. + +Request body: + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `caller_id` | string | Yes | caller instance, node, or worker identity | +| `task_id` | string | Yes | unique task ID | +| `email_domain` | string | Yes | mailbox domain to claim from, for example `zerodotsix.top` | +| `provider` | string | No | optional provider filter | +| `project_key` | string | No | project-level reuse and duplicate-prevention context | + +If `email_domain` is missing or blank, the endpoint returns HTTP `400` with code `EMAIL_DOMAIN_REQUIRED`. + +Copy-paste example: + +```bash +curl -X POST https://api.example.com/api/external/pool/claim-domain \ + -H "X-API-Key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "caller_id": "reg-worker-001", + "task_id": "task-20260409-0001", + "email_domain": "zerodotsix.top" + }' +``` + ### `POST /api/external/pool/claim-release` Request body: diff --git a/tests/test_pool_flow_suite.py b/tests/test_pool_flow_suite.py index 17b70779..92594abe 100644 --- a/tests/test_pool_flow_suite.py +++ b/tests/test_pool_flow_suite.py @@ -295,6 +295,39 @@ def test_claim_response_includes_email_domain_and_claimed_at(self): self.assertIn("claimed_at", data["data"]) self.assertIsNotNone(data["data"]["claimed_at"]) + def test_claim_domain_requires_email_domain(self): + resp = self.client.post( + "/api/external/pool/claim-domain", + headers=self._auth_headers(), + json={"caller_id": "domain_bot", "task_id": "domain_required"}, + ) + self.assertEqual(resp.status_code, 400) + data = json.loads(resp.data) + self.assertFalse(data["success"]) + self.assertEqual(data["code"], "EMAIL_DOMAIN_REQUIRED") + + def test_claim_domain_filters_to_requested_domain(self): + requested_domain = f"requested_{uuid.uuid4().hex[:8]}.test" + other_domain = f"other_{uuid.uuid4().hex[:8]}.test" + requested = self._make_pool_account(email_domain=requested_domain) + other = self._make_pool_account(email_domain=other_domain) + + resp = self.client.post( + "/api/external/pool/claim-domain", + headers=self._auth_headers(), + json={ + "caller_id": "domain_bot", + "task_id": "domain_claim", + "email_domain": requested_domain.upper(), + }, + ) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.data) + self.assertTrue(data["success"]) + self.assertEqual(data["data"]["account_id"], requested["id"]) + self.assertEqual(data["data"]["email_domain"], requested_domain) + self.assertNotEqual(data["data"]["account_id"], other["id"]) + def test_claim_with_project_key_prevents_same_project_reuse_without_manual_status_reset(self): """同 caller_id + project_key 下,新语义应原生阻止再次领取,无需手工改状态。""" # 使用唯一的 email_domain 隔离测试数据 diff --git "a/\346\263\250\345\206\214\344\270\216\351\202\256\347\256\261\346\261\240\346\216\245\345\217\243\346\226\207\346\241\243.md" "b/\346\263\250\345\206\214\344\270\216\351\202\256\347\256\261\346\261\240\346\216\245\345\217\243\346\226\207\346\241\243.md" index a6e98b38..22c30e03 100644 --- "a/\346\263\250\345\206\214\344\270\216\351\202\256\347\256\261\346\261\240\346\216\245\345\217\243\346\226\207\346\241\243.md" +++ "b/\346\263\250\345\206\214\344\270\216\351\202\256\347\256\261\346\261\240\346\216\245\345\217\243\346\226\207\346\241\243.md" @@ -103,7 +103,8 @@ X-API-Key: YOUR_API_KEY | 接口 | 说明 | 是否推荐 | | --- | --- | --- | -| `POST /api/external/pool/claim-random` | 领取邮箱 | 常用 | +| `POST /api/external/pool/claim-random` | 随机领取邮箱,可选按域名过滤 | 常用 | +| `POST /api/external/pool/claim-domain` | 指定域名领取邮箱 | 常用 | | `POST /api/external/pool/claim-release` | 释放邮箱 | 常用 | | `POST /api/external/pool/claim-complete` | 回传任务结果 | 常用 | | `GET /api/external/pool/stats` | 查看池状态 | 可选 | @@ -323,12 +324,15 @@ curl -X GET https://api.example.com/api/external/health \ | `caller_id` | string | 是 | 调用方实例、节点或 worker 标识 | | `task_id` | string | 是 | 当前任务唯一 ID | | `provider` | string | 否 | 提供商筛选:`outlook` / `imap` / `custom` / `cloudflare_temp_mail` | +| `project_key` | string | 否 | 项目维度复用与防重复上下文 | +| `email_domain` | string | 否 | 邮箱域名筛选;传入后只在该域名下领取可用邮箱 | 当前实现说明: -- 当前池接口只支持按 `provider` 筛选 -- `outlook.com`、`hotmail.com`、`live.com`、`live.cn` 当前都归属于 `provider=outlook` -- 当前对外池接口不支持按域名、分组、标签进一步筛选 +- `claim-random` 默认随机领取可用邮箱;传入 `email_domain` 时,会在该域名范围内随机领取 +- 如需语义更明确的指定域名领取,可使用 `POST /api/external/pool/claim-domain`,该接口要求 `email_domain` 必填 +- `outlook.com`、`hotmail.com`、`live.com`、`live.cn` 当前都归属于 `provider=outlook`,如需区分这些域名,请使用 `email_domain` +- 当前对外池接口不支持指定完整邮箱、分组或标签领取 - 当 `provider=cloudflare_temp_mail` 且池中无可用邮箱时,服务会动态创建 CF 临时邮箱并直接返回领取结果 成功返回字段: @@ -390,6 +394,35 @@ curl -X POST https://api.example.com/api/external/pool/claim-random \ } ``` +### `POST /api/external/pool/claim-domain` + +用途:指定域名领取邮箱。该接口是 `claim-random + email_domain` 的显式入口,内部复用同一套邮箱池领取、租约、审计和回传状态机。 + +请求体参数: + +| 参数名 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `caller_id` | string | 是 | 调用方实例、节点或 worker 标识 | +| `task_id` | string | 是 | 当前任务唯一 ID | +| `email_domain` | string | 是 | 指定领取的邮箱域名,例如 `zerodotsix.top` | +| `provider` | string | 否 | 可选提供商筛选 | +| `project_key` | string | 否 | 项目维度复用与防重复上下文 | + +如果未传入 `email_domain` 或传入空字符串,返回 HTTP `400`,错误码为 `EMAIL_DOMAIN_REQUIRED`。 + +可复制示例: + +```bash +curl -X POST https://api.example.com/api/external/pool/claim-domain \ + -H "X-API-Key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "caller_id": "reg-worker-001", + "task_id": "task-20260409-0001", + "email_domain": "zerodotsix.top" + }' +``` + ### `POST /api/external/pool/claim-release` 请求体参数: From 2850fb7af6300cdad00cf0008f5d8c12151d51f2 Mon Sep 17 00:00:00 2001 From: CodeXWeb Date: Sun, 5 Jul 2026 16:52:55 +0000 Subject: [PATCH 2/2] test: include claim-domain csrf exemption --- tests/test_external_pool.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_external_pool.py b/tests/test_external_pool.py index 4b099ec8..303bc118 100644 --- a/tests/test_external_pool.py +++ b/tests/test_external_pool.py @@ -280,6 +280,7 @@ def fake_csrf_exempt(handler): set(wrapped_handlers), { "api_external_pool_claim_random", + "api_external_pool_claim_domain", "api_external_pool_claim_release", "api_external_pool_claim_complete", "api_external_pool_stats",