From c597effb18fae11701a08f81619643c54dbdd830 Mon Sep 17 00:00:00 2001 From: liutao Date: Thu, 2 Apr 2026 10:39:35 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E4=B8=9A=E5=8A=A1=E7=BB=B4=E5=BA=A6=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E5=90=8Cprovider=E4=B8=8B=E4=B8=8D=E5=90=8C?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=20-=20=E6=A0=B9=E6=8D=AE=E4=B8=9A=E5=8A=A1pr?= =?UTF-8?q?oject=E7=BB=B4=E5=BA=A6=E5=AE=9A=E4=B9=89used=E7=8A=B6=E6=80=81?= =?UTF-8?q?=20-=20=E5=8F=AF=E8=8E=B7=E5=8F=96=E4=B8=8D=E5=90=8Cprovider?= =?UTF-8?q?=E4=B8=8B=E4=B8=8D=E5=90=8Cemail=5Fdomain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- outlook_web/controllers/emails.py | 55 ++- outlook_web/controllers/external_pool.py | 126 ++++--- outlook_web/controllers/system.py | 1 + outlook_web/db.py | 57 +++- outlook_web/repositories/accounts.py | 34 +- outlook_web/repositories/pool.py | 315 +++++++++++++++--- outlook_web/services/external_api.py | 122 ++++++- outlook_web/services/pool.py | 280 +++++++++++----- outlook_web/services/providers.py | 81 +++-- registration-mail-pool-api.en.md | 51 ++- tests/test_domain_provider_map.py | 14 + tests/test_external_api.py | 133 ++++++++ tests/test_pool_flow_suite.py | 175 +++++----- ...45\345\217\243\346\226\207\346\241\243.md" | 51 ++- 14 files changed, 1154 insertions(+), 341 deletions(-) diff --git a/outlook_web/controllers/emails.py b/outlook_web/controllers/emails.py index 358df5a5..9106f3c8 100644 --- a/outlook_web/controllers/emails.py +++ b/outlook_web/controllers/emails.py @@ -709,10 +709,15 @@ def api_extract_verification(email_addr: str) -> Any: def _parse_external_common_args(*, default_since_minutes: int | None = None) -> dict: """解析 external API 通用 query 参数(按 TDD-00008 做基础校验)。""" + claim_token = (request.args.get("claim_token") or "").strip() email_addr = (request.args.get("email") or "").strip() - if not email_addr or "@" not in email_addr: - raise external_api_service.InvalidParamError("email 参数无效") - external_api_service.ensure_external_email_access(email_addr) + scope = external_api_service.resolve_external_mail_scope( + email_addr=email_addr or None, + claim_token=claim_token or None, + ) + resolved_email = scope["email"] + claim_context = scope.get("claim_context") + baseline_timestamp = external_api_service.claimed_at_to_timestamp(scope.get("claimed_at")) folder = (request.args.get("folder") or "inbox").strip().lower() or "inbox" if folder not in {"inbox", "junkemail", "deleteditems"}: @@ -745,7 +750,10 @@ def _int_arg(name: str, default: int) -> int: raise external_api_service.InvalidParamError("since_minutes 参数无效") return { - "email": email_addr, + "email": resolved_email, + "claim_token": claim_token, + "claim_context": claim_context, + "baseline_timestamp": baseline_timestamp, "folder": folder, "skip": skip, "top": top, @@ -802,6 +810,7 @@ def api_external_get_messages() -> Any: from_contains=args["from_contains"], subject_contains=args["subject_contains"], since_minutes=args["since_minutes"], + baseline_timestamp=args["baseline_timestamp"], ) external_api_service.audit_external_api_access( @@ -844,6 +853,7 @@ def api_external_get_latest_message() -> Any: from_contains=args["from_contains"], subject_contains=args["subject_contains"], since_minutes=args["since_minutes"], + baseline_timestamp=args["baseline_timestamp"], ) external_api_service.audit_external_api_access( action="external_api_access", @@ -977,6 +987,8 @@ def api_external_get_verification_code() -> Any: from_contains=args["from_contains"], subject_contains=args["subject_contains"], since_minutes=args["since_minutes"], + baseline_timestamp=args["baseline_timestamp"], + claim_token=args["claim_token"] or None, code_regex=code_regex, code_length=code_length, code_source=code_source, @@ -984,6 +996,16 @@ def api_external_get_verification_code() -> Any: if not result.get("verification_code"): raise external_api_service.VerificationCodeNotFoundError("未找到符合条件的验证码邮件") + external_api_service.record_claim_read_context( + claim_token=args["claim_token"] or None, + action="claim", + payload={ + "last_read_action": "verification-code", + "matched_email_id": result.get("matched_email_id"), + "received_at": result.get("received_at"), + }, + ) + external_api_service.audit_external_api_access( action="external_api_access", email_addr=args["email"] or "", @@ -1032,10 +1054,22 @@ def api_external_get_verification_link() -> Any: from_contains=args["from_contains"], subject_contains=args["subject_contains"], since_minutes=args["since_minutes"], + baseline_timestamp=args["baseline_timestamp"], + claim_token=args["claim_token"] or None, ) if not result.get("verification_link"): raise external_api_service.VerificationLinkNotFoundError("未找到符合条件的验证链接邮件") + external_api_service.record_claim_read_context( + claim_token=args["claim_token"] or None, + action="claim", + payload={ + "last_read_action": "verification-link", + "matched_email_id": result.get("matched_email_id"), + "received_at": result.get("received_at"), + }, + ) + external_api_service.audit_external_api_access( action="external_api_access", email_addr=args["email"] or "", @@ -1083,6 +1117,8 @@ def api_external_wait_message() -> Any: from_contains=args["from_contains"], subject_contains=args["subject_contains"], since_minutes=args["since_minutes"], + baseline_timestamp=args["baseline_timestamp"], + claim_token=args["claim_token"] or None, ) external_api_service.audit_external_api_access( action="external_api_access", @@ -1102,6 +1138,17 @@ def api_external_wait_message() -> Any: from_contains=args["from_contains"], subject_contains=args["subject_contains"], since_minutes=args["since_minutes"], + baseline_timestamp=args["baseline_timestamp"], + claim_token=args["claim_token"] or None, + ) + external_api_service.record_claim_read_context( + claim_token=args["claim_token"] or None, + action="claim", + payload={ + "last_read_action": "wait-message", + "matched_email_id": result.get("id"), + "received_at": result.get("created_at"), + }, ) external_api_service.audit_external_api_access( action="external_api_access", diff --git a/outlook_web/controllers/external_pool.py b/outlook_web/controllers/external_pool.py index 845bbb82..33b22e41 100644 --- a/outlook_web/controllers/external_pool.py +++ b/outlook_web/controllers/external_pool.py @@ -4,6 +4,7 @@ from flask import jsonify, request +from outlook_web.repositories import accounts as accounts_repo from outlook_web.repositories import settings as settings_repo from outlook_web.security.auth import api_key_required, get_external_api_consumer from outlook_web.security.external_api_guard import external_api_guards @@ -40,11 +41,7 @@ def _error_response(endpoint: str, exc: PoolServiceError): def _check_pool_external_enabled(endpoint: str): if settings_repo.get_pool_external_enabled(): return None - _audit( - endpoint, - "error", - details={"code": "FEATURE_DISABLED", "feature": "external_pool"}, - ) + _audit(endpoint, "error", details={"code": "FEATURE_DISABLED", "feature": "external_pool"}) return ( jsonify( external_api_service.fail( @@ -64,11 +61,7 @@ def _check_pool_access(endpoint: str): _audit( endpoint, "error", - details={ - "code": "FORBIDDEN", - "feature": "external_pool", - "reason": "pool_access_required", - }, + details={"code": "FORBIDDEN", "feature": "external_pool", "reason": "pool_access_required"}, ) return ( jsonify( @@ -82,6 +75,32 @@ def _check_pool_access(endpoint: str): ) +def _get_consumer_key() -> str: + consumer = get_external_api_consumer() or {} + return str(consumer.get("consumer_key") or "").strip() + + +def _account_payload(account_id: int, *, pool_status: str) -> dict[str, Any]: + account = accounts_repo.get_account_by_id(account_id) or {} + return { + "account_id": account_id, + "pool_status": pool_status, + "provider": account.get("provider") or "", + "email_domain": account.get("email_domain") or "", + } + + +def _parse_account_id(endpoint: str, raw_value) -> int | tuple: + if raw_value is None: + _audit(endpoint, "error", details={"code": "ACCOUNT_ID_MISSING"}) + return jsonify(external_api_service.fail("ACCOUNT_ID_MISSING", "account_id 不能为空")), 400 + try: + return int(raw_value) + except (TypeError, ValueError): + _audit(endpoint, "error", details={"code": "ACCOUNT_ID_INVALID"}) + return jsonify(external_api_service.fail("ACCOUNT_ID_INVALID", "account_id 必须为整数")), 400 + + @api_key_required @external_api_guards(feature="pool_claim_random") def api_external_pool_claim_random(): @@ -92,37 +111,49 @@ def api_external_pool_claim_random(): access_resp = _check_pool_access(endpoint) if access_resp is not None: return access_resp + + consumer_key = _get_consumer_key() body = request.get_json(silent=True) or {} caller_id = body.get("caller_id", "") task_id = body.get("task_id", "") + project_key = body.get("project_key", "") provider = body.get("provider") + email_domain = body.get("email_domain") try: account = claim_random( + consumer_key=consumer_key, + project_key=project_key, caller_id=caller_id, task_id=task_id, provider=provider, + email_domain=email_domain, ) data = { "account_id": account["id"], "email": account["email"], + "provider": account.get("provider") or "", + "email_domain": account.get("email_domain") or "", "claim_token": account["claim_token"], + "claimed_at": account.get("claimed_at") or "", "lease_expires_at": account["lease_expires_at"], } _audit( endpoint, "ok", - details={"provider": provider or "", "account_id": data["account_id"]}, + details={ + "provider": data["provider"], + "email_domain": data["email_domain"], + "project_key": project_key, + "account_id": data["account_id"], + }, + email_addr=data["email"], ) return jsonify(external_api_service.ok(data)) except PoolServiceError as exc: return _error_response(endpoint, exc) except Exception as exc: - _audit( - endpoint, - "error", - details={"code": "INTERNAL_ERROR", "err": type(exc).__name__}, - ) + _audit(endpoint, "error", details={"code": "INTERNAL_ERROR", "err": type(exc).__name__}) return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 @@ -136,40 +167,35 @@ def api_external_pool_claim_release(): access_resp = _check_pool_access(endpoint) if access_resp is not None: return access_resp + + consumer_key = _get_consumer_key() body = request.get_json(silent=True) or {} - account_id = body.get("account_id") + account_id = _parse_account_id(endpoint, body.get("account_id")) + if isinstance(account_id, tuple): + return account_id + claim_token = body.get("claim_token", "") caller_id = body.get("caller_id", "") task_id = body.get("task_id", "") + project_key = body.get("project_key", "") reason = body.get("reason") - 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 - 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 - try: release_claim( + consumer_key=consumer_key, + project_key=project_key, account_id=account_id, claim_token=claim_token, caller_id=caller_id, task_id=task_id, reason=reason, ) - _audit(endpoint, "ok", details={"account_id": account_id}) - return jsonify(external_api_service.ok({"account_id": account_id, "pool_status": "available"})) + _audit(endpoint, "ok", details={"account_id": account_id, "project_key": project_key}) + return jsonify(external_api_service.ok(_account_payload(account_id, pool_status="available"))) except PoolServiceError as exc: return _error_response(endpoint, exc) except Exception as exc: - _audit( - endpoint, - "error", - details={"code": "INTERNAL_ERROR", "err": type(exc).__name__}, - ) + _audit(endpoint, "error", details={"code": "INTERNAL_ERROR", "err": type(exc).__name__}) return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 @@ -183,25 +209,24 @@ def api_external_pool_claim_complete(): access_resp = _check_pool_access(endpoint) if access_resp is not None: return access_resp + + consumer_key = _get_consumer_key() body = request.get_json(silent=True) or {} - account_id = body.get("account_id") + account_id = _parse_account_id(endpoint, body.get("account_id")) + if isinstance(account_id, tuple): + return account_id + claim_token = body.get("claim_token", "") caller_id = body.get("caller_id", "") task_id = body.get("task_id", "") + project_key = body.get("project_key", "") result = body.get("result", "") detail = body.get("detail") - 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 - 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 - try: new_status = complete_claim( + consumer_key=consumer_key, + project_key=project_key, account_id=account_id, claim_token=claim_token, caller_id=caller_id, @@ -214,19 +239,16 @@ def api_external_pool_claim_complete(): "ok", details={ "account_id": account_id, + "project_key": project_key, "result": result, "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_payload(account_id, pool_status=new_status))) except PoolServiceError as exc: return _error_response(endpoint, exc) except Exception as exc: - _audit( - endpoint, - "error", - details={"code": "INTERNAL_ERROR", "err": type(exc).__name__}, - ) + _audit(endpoint, "error", details={"code": "INTERNAL_ERROR", "err": type(exc).__name__}) return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 @@ -245,9 +267,5 @@ def api_external_pool_stats(): _audit(endpoint, "ok", details={"snapshot": True}) return jsonify(external_api_service.ok(stats)) except Exception as exc: - _audit( - endpoint, - "error", - details={"code": "INTERNAL_ERROR", "err": type(exc).__name__}, - ) + _audit(endpoint, "error", details={"code": "INTERNAL_ERROR", "err": type(exc).__name__}) return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 diff --git a/outlook_web/controllers/system.py b/outlook_web/controllers/system.py index fb802517..924d05c3 100644 --- a/outlook_web/controllers/system.py +++ b/outlook_web/controllers/system.py @@ -398,6 +398,7 @@ def api_external_account_status() -> Any: "exists": True, "account_type": account_type, "provider": provider, + "email_domain": account.get("email_domain") or "", "group_id": account.get("group_id"), "status": account.get("status"), "last_refresh_at": account.get("last_refresh_at"), diff --git a/outlook_web/db.py b/outlook_web/db.py index 7edf121d..c1fa6d40 100644 --- a/outlook_web/db.py +++ b/outlook_web/db.py @@ -15,6 +15,7 @@ is_encrypted, is_password_hashed, ) +from outlook_web.services.providers import extract_email_domain # 数据库 Schema 版本(用于升级可验证/可诊断) # v3:对齐 PRD-00005 / FD-00005 / TDD-00005(accounts 表新增多邮箱字段:account_type/provider/imap_host/imap_port/imap_password) @@ -28,7 +29,8 @@ # v12:PRD-00009 P2 — external_api_keys 新增 pool_access 布尔权限 # v13:PRD-00010 V1.90 — 邮件通知设置 + 统一通知游标/投递日志表 # v14:PRD-00011 V1.91 — accounts 表新增简洁模式摘要字段,/api/accounts 只读持久化摘要 -DB_SCHEMA_VERSION = 14 +# v15:OpenSpec project-scoped-pool-reuse — email_domain + account_project_usage + claim context 扩展 +DB_SCHEMA_VERSION = 15 DB_SCHEMA_VERSION_KEY = "db_schema_version" DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY = "db_schema_last_upgrade_trace_id" DB_SCHEMA_LAST_UPGRADE_ERROR_KEY = "db_schema_last_upgrade_error" @@ -175,6 +177,7 @@ def init_db(database_path: Optional[str] = None): CREATE TABLE IF NOT EXISTS accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, + email_domain TEXT DEFAULT '', password TEXT, client_id TEXT NOT NULL, refresh_token TEXT NOT NULL, @@ -360,6 +363,8 @@ def init_db(database_path: Optional[str] = None): 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'") + if "email_domain" not in columns: + cursor.execute("ALTER TABLE accounts ADD COLUMN email_domain TEXT DEFAULT ''") if "imap_host" not in columns: cursor.execute("ALTER TABLE accounts ADD COLUMN imap_host TEXT") if "imap_port" not in columns: @@ -738,15 +743,27 @@ def init_db(database_path: Optional[str] = None): id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, claim_token TEXT NOT NULL, + consumer_key TEXT NOT NULL DEFAULT '', + project_key TEXT NOT NULL DEFAULT '', caller_id TEXT NOT NULL, task_id TEXT NOT NULL, action TEXT NOT NULL, result TEXT DEFAULT NULL, detail TEXT DEFAULT NULL, + claim_read_context TEXT DEFAULT NULL, created_at TEXT NOT NULL, FOREIGN KEY (account_id) REFERENCES accounts(id) ) """) + cursor.execute("PRAGMA table_info(account_claim_logs)") + claim_log_columns = [col[1] for col in cursor.fetchall()] + for col_def in [ + ("consumer_key", "TEXT NOT NULL DEFAULT ''"), + ("project_key", "TEXT NOT NULL DEFAULT ''"), + ("claim_read_context", "TEXT DEFAULT NULL"), + ]: + if col_def[0] not in claim_log_columns: + cursor.execute(f"ALTER TABLE account_claim_logs ADD COLUMN {col_def[0]} {col_def[1]}") cursor.execute(""" CREATE INDEX IF NOT EXISTS idx_claim_logs_account_id ON account_claim_logs(account_id) @@ -759,10 +776,48 @@ def init_db(database_path: Optional[str] = None): CREATE INDEX IF NOT EXISTS idx_claim_logs_claim_token ON account_claim_logs(claim_token) """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_claim_logs_consumer_project + ON account_claim_logs(consumer_key, project_key, action) + """) cursor.execute(""" CREATE INDEX IF NOT EXISTS idx_accounts_pool_status ON accounts(pool_status) """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_accounts_email_domain + ON accounts(email_domain) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS account_project_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + consumer_key TEXT NOT NULL, + project_key TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(account_id, consumer_key, project_key), + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE + ) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_account_project_usage_lookup + ON account_project_usage(consumer_key, project_key, account_id) + """) + + rows_missing_domain = cursor.execute( + """ + SELECT id, email + FROM accounts + WHERE COALESCE(email_domain, '') = '' + """ + ).fetchall() + for row in rows_missing_domain: + normalized_domain = extract_email_domain(row["email"]) + cursor.execute( + "UPDATE accounts SET email_domain = ? WHERE id = ?", + (normalized_domain, row["id"]), + ) 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')") diff --git a/outlook_web/repositories/accounts.py b/outlook_web/repositories/accounts.py index 36684016..a3c7bff6 100644 --- a/outlook_web/repositories/accounts.py +++ b/outlook_web/repositories/accounts.py @@ -5,6 +5,7 @@ from outlook_web.db import get_db from outlook_web.security.crypto import decrypt_data, encrypt_data +from outlook_web.services.providers import extract_email_domain COMPACT_SUMMARY_FIELDS = ( "latest_email_subject", @@ -34,6 +35,12 @@ def _decrypt_account_field(account: Dict[str, Any], field_name: str) -> None: ) +def _normalize_account_email_domain(account: Dict[str, Any]) -> None: + if account.get("email_domain"): + return + account["email_domain"] = extract_email_domain(account.get("email") or "") + + def load_accounts(group_id: int = None) -> List[Dict]: """从数据库加载邮箱账号(自动解密敏感字段,批量加载 tags 避免 N+1)""" db = get_db() @@ -89,6 +96,7 @@ def load_accounts(group_id: int = None) -> List[Dict]: accounts: List[Dict[str, Any]] = [] for row in rows: account = dict(row) + _normalize_account_email_domain(account) _decrypt_account_field(account, "password") _decrypt_account_field(account, "refresh_token") _decrypt_account_field(account, "imap_password") @@ -107,11 +115,21 @@ def load_accounts(group_id: int = None) -> List[Dict]: def get_account_by_email(email_addr: str) -> Optional[Dict]: """根据邮箱地址获取账号(自动解密敏感字段)""" db = get_db() - cursor = db.execute("SELECT * FROM accounts WHERE email = ?", (email_addr,)) + cursor = db.execute( + """ + SELECT * + FROM accounts + WHERE email = ? COLLATE NOCASE + ORDER BY CASE WHEN email = ? THEN 0 ELSE 1 END + LIMIT 1 + """, + (email_addr, email_addr), + ) row = cursor.fetchone() if not row: return None account = dict(row) + _normalize_account_email_domain(account) _decrypt_account_field(account, "password") _decrypt_account_field(account, "refresh_token") _decrypt_account_field(account, "imap_password") @@ -134,6 +152,7 @@ def get_account_by_id(account_id: int) -> Optional[Dict]: if not row: return None account = dict(row) + _normalize_account_email_domain(account) _decrypt_account_field(account, "password") _decrypt_account_field(account, "refresh_token") _decrypt_account_field(account, "imap_password") @@ -178,21 +197,23 @@ def add_account( 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 + email_domain = extract_email_domain(email_addr) db.execute( """ INSERT INTO accounts ( email, password, client_id, refresh_token, - account_type, provider, imap_host, imap_port, imap_password, + email_domain, account_type, provider, imap_host, imap_port, imap_password, group_id, remark, pool_status ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( email_addr, encrypted_password, client_id or "", encrypted_refresh_token, + email_domain, account_type, provider, imap_host or "", @@ -247,11 +268,13 @@ def update_account( if not email_addr: return False + email_domain = extract_email_domain(email_addr) db.execute( """ UPDATE accounts SET email = ?, + email_domain = ?, imap_password = ?, group_id = ?, remark = ?, @@ -261,6 +284,7 @@ def update_account( """, ( email_addr, + email_domain, encrypted_imap_password, group_id, remark, @@ -283,16 +307,18 @@ def update_account( if not email_addr or not new_client_id or not encrypted_refresh_token: return False + email_domain = extract_email_domain(email_addr) db.execute( """ UPDATE accounts - SET email = ?, password = ?, client_id = ?, refresh_token = ?, + SET email = ?, email_domain = ?, password = ?, client_id = ?, refresh_token = ?, group_id = ?, remark = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, ( email_addr, + email_domain, encrypted_password, new_client_id, encrypted_refresh_token, diff --git a/outlook_web/repositories/pool.py b/outlook_web/repositories/pool.py index 620db6e9..5b33af87 100644 --- a/outlook_web/repositories/pool.py +++ b/outlook_web/repositories/pool.py @@ -1,12 +1,15 @@ from __future__ import annotations +import json import secrets import sqlite3 from datetime import datetime, timedelta, timezone from typing import Dict, List, Optional +from outlook_web.services.providers import extract_email_domain + RESULT_TO_POOL_STATUS: Dict[str, str] = { - "success": "used", + "success": "cooldown", "verification_timeout": "cooldown", "provider_blocked": "frozen", "credential_invalid": "retired", @@ -18,12 +21,88 @@ def _utcnow() -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) +def _iso_now() -> str: + return _utcnow().isoformat() + "Z" + + +def _build_claimed_by(*, consumer_key: str, project_key: str, caller_id: str, task_id: str) -> str: + return "||".join([consumer_key, project_key, caller_id, task_id]) + + +def parse_claimed_by(claimed_by: str | None) -> dict: + parts = str(claimed_by or "").split("||") + if len(parts) == 4: + return { + "consumer_key": parts[0], + "project_key": parts[1], + "caller_id": parts[2], + "task_id": parts[3], + } + + legacy_parts = str(claimed_by or ":").split(":", 1) + caller_id = legacy_parts[0] + task_id = legacy_parts[1] if len(legacy_parts) > 1 else "" + return { + "consumer_key": "", + "project_key": "", + "caller_id": caller_id, + "task_id": task_id, + } + + +def _write_claim_log( + conn: sqlite3.Connection, + *, + account_id: int, + claim_token: str, + consumer_key: str, + project_key: str, + caller_id: str, + task_id: str, + action: str, + result: str | None, + detail: str | None, + claim_read_context: dict | None = None, + created_at: str | None = None, +) -> None: + conn.execute( + """ + INSERT INTO account_claim_logs ( + account_id, claim_token, consumer_key, project_key, + caller_id, task_id, action, result, detail, claim_read_context, created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + account_id, + claim_token, + consumer_key, + project_key, + caller_id, + task_id, + action, + result, + detail, + json.dumps(claim_read_context, ensure_ascii=False) if claim_read_context else None, + created_at or _iso_now(), + ), + ) + + +def _normalized_email_domain_from_row(row) -> str: + return str(row["email_domain"] or "").strip().lower() or extract_email_domain(row["email"] or "") + + def claim_atomic( conn: sqlite3.Connection, + *, + consumer_key: str, + project_key: str, caller_id: str, task_id: str, lease_seconds: int, provider: Optional[str] = None, + email_domain: Optional[str] = None, group_id: Optional[int] = None, tags: Optional[List[str]] = None, exclude_recent_minutes: Optional[int] = None, @@ -32,13 +111,24 @@ def claim_atomic( SELECT a.* FROM accounts a WHERE a.pool_status = 'available' AND a.status = 'active' + AND NOT EXISTS ( + SELECT 1 + FROM account_project_usage apu + WHERE apu.account_id = a.id + AND apu.consumer_key = ? + AND apu.project_key = ? + ) """ - params: list = [] + params: list = [consumer_key, project_key] if provider: sql += " AND a.provider = ?" params.append(provider) + if email_domain: + sql += " AND a.email_domain = ?" + params.append(email_domain) + if group_id is not None: sql += " AND a.group_id = ?" params.append(group_id) @@ -63,14 +153,19 @@ def claim_atomic( conn.execute("BEGIN IMMEDIATE") account = conn.execute(sql, params).fetchone() - if account is None: conn.execute("ROLLBACK") return None - now_str = _utcnow().isoformat() + "Z" + now_str = _iso_now() lease_expires_at_str = (_utcnow() + timedelta(seconds=lease_seconds)).isoformat() + "Z" token = "clm_" + secrets.token_urlsafe(9) + claimed_by = _build_claimed_by( + consumer_key=consumer_key, + project_key=project_key, + caller_id=caller_id, + task_id=task_id, + ) conn.execute( """ @@ -85,7 +180,7 @@ def claim_atomic( WHERE id = ? """, ( - f"{caller_id}:{task_id}", + claimed_by, now_str, lease_expires_at_str, token, @@ -94,30 +189,46 @@ def claim_atomic( account["id"], ), ) - conn.execute( - """ - INSERT INTO account_claim_logs - (account_id, claim_token, caller_id, task_id, action, result, detail, created_at) - VALUES (?, ?, ?, ?, 'claim', NULL, NULL, ?) - """, - (account["id"], token, caller_id, task_id, now_str), + _write_claim_log( + conn, + account_id=account["id"], + claim_token=token, + consumer_key=consumer_key, + project_key=project_key, + caller_id=caller_id, + task_id=task_id, + action="claim", + result=None, + detail=None, + claim_read_context={ + "email": account["email"], + "email_domain": _normalized_email_domain_from_row(account), + "provider": account["provider"] or "", + "claimed_at": now_str, + }, + created_at=now_str, ) conn.execute("COMMIT") return dict(account) | { + "email_domain": _normalized_email_domain_from_row(account), "claim_token": token, "lease_expires_at": lease_expires_at_str, + "claimed_at": now_str, } def release( conn: sqlite3.Connection, + *, account_id: int, claim_token: str, + consumer_key: str, + project_key: str, caller_id: str, task_id: str, reason: Optional[str], ) -> None: - now_str = _utcnow().isoformat() + "Z" + now_str = _iso_now() conn.execute("BEGIN IMMEDIATE") conn.execute( """ @@ -132,21 +243,29 @@ def release( """, (now_str, account_id), ) - conn.execute( - """ - INSERT INTO account_claim_logs - (account_id, claim_token, caller_id, task_id, action, result, detail, created_at) - VALUES (?, ?, ?, ?, 'release', 'manual_release', ?, ?) - """, - (account_id, claim_token, caller_id, task_id, reason, now_str), + _write_claim_log( + conn, + account_id=account_id, + claim_token=claim_token, + consumer_key=consumer_key, + project_key=project_key, + caller_id=caller_id, + task_id=task_id, + action="release", + result="manual_release", + detail=reason, + created_at=now_str, ) conn.execute("COMMIT") def complete( conn: sqlite3.Connection, + *, account_id: int, claim_token: str, + consumer_key: str, + project_key: str, caller_id: str, task_id: str, result: str, @@ -154,9 +273,20 @@ def complete( ) -> str: new_pool_status = RESULT_TO_POOL_STATUS[result] is_success = result == "success" - now_str = _utcnow().isoformat() + "Z" + now_str = _iso_now() conn.execute("BEGIN IMMEDIATE") + if is_success: + conn.execute( + """ + INSERT OR IGNORE INTO account_project_usage ( + account_id, consumer_key, project_key, created_at + ) + VALUES (?, ?, ?, ?) + """, + (account_id, consumer_key, project_key, now_str), + ) + conn.execute( """ UPDATE accounts SET @@ -182,33 +312,36 @@ def complete( account_id, ), ) - conn.execute( - """ - INSERT INTO account_claim_logs - (account_id, claim_token, caller_id, task_id, action, result, detail, created_at) - VALUES (?, ?, ?, ?, 'complete', ?, ?, ?) - """, - (account_id, claim_token, caller_id, task_id, result, detail, now_str), + _write_claim_log( + conn, + account_id=account_id, + claim_token=claim_token, + consumer_key=consumer_key, + project_key=project_key, + caller_id=caller_id, + task_id=task_id, + action="complete", + result=result, + detail=detail, + created_at=now_str, ) conn.execute("COMMIT") return new_pool_status def expire_stale_claims(conn: sqlite3.Connection) -> int: - now_str = _utcnow().isoformat() + "Z" + now_str = _iso_now() expired = conn.execute( """ - SELECT id, claim_token, claimed_by FROM accounts + SELECT id, claim_token, claimed_by + FROM accounts WHERE pool_status = 'claimed' AND lease_expires_at < ? """, (now_str,), ).fetchall() for account in expired: - parts = (account["claimed_by"] or ":").split(":", 1) - caller_id = parts[0] - task_id = parts[1] if len(parts) > 1 else "" - + claimed_context = parse_claimed_by(account["claimed_by"]) conn.execute("BEGIN IMMEDIATE") conn.execute( """ @@ -225,13 +358,18 @@ def expire_stale_claims(conn: sqlite3.Connection) -> int: """, (now_str, account["id"]), ) - conn.execute( - """ - INSERT INTO account_claim_logs - (account_id, claim_token, caller_id, task_id, action, result, detail, created_at) - VALUES (?, ?, ?, ?, 'expire', 'lease_expired', 'lease timeout, auto moved to cooldown', ?) - """, - (account["id"], account["claim_token"], caller_id, task_id, now_str), + _write_claim_log( + conn, + account_id=account["id"], + claim_token=account["claim_token"], + consumer_key=claimed_context["consumer_key"], + project_key=claimed_context["project_key"], + caller_id=claimed_context["caller_id"], + task_id=claimed_context["task_id"], + action="expire", + result="lease_expired", + detail="lease timeout, auto moved to cooldown", + created_at=now_str, ) conn.execute("COMMIT") @@ -240,7 +378,7 @@ def expire_stale_claims(conn: sqlite3.Connection) -> int: def recover_cooldown(conn: sqlite3.Connection, cooldown_seconds: int) -> int: cutoff_str = (_utcnow() - timedelta(seconds=cooldown_seconds)).isoformat() + "Z" - now_str = _utcnow().isoformat() + "Z" + now_str = _iso_now() cursor = conn.execute( """ UPDATE accounts SET pool_status = 'available', updated_at = ? @@ -252,11 +390,99 @@ def recover_cooldown(conn: sqlite3.Connection, cooldown_seconds: int) -> int: return cursor.rowcount +def get_claim_context_by_token(conn: sqlite3.Connection, claim_token: str) -> Optional[dict]: + row = conn.execute( + """ + SELECT + a.id, + a.email, + a.email_domain, + a.provider, + a.claim_token, + a.claimed_at, + a.claimed_by, + a.pool_status + FROM accounts a + WHERE a.claim_token = ? + """, + (claim_token,), + ).fetchone() + if row is None: + return None + + claimed_context = parse_claimed_by(row["claimed_by"]) + return { + "account_id": row["id"], + "email": row["email"], + "email_domain": _normalized_email_domain_from_row(row), + "provider": row["provider"] or "", + "claim_token": row["claim_token"], + "claimed_at": row["claimed_at"] or "", + "pool_status": row["pool_status"] or "", + "consumer_key": claimed_context["consumer_key"], + "project_key": claimed_context["project_key"], + "caller_id": claimed_context["caller_id"], + "task_id": claimed_context["task_id"], + } + + +def get_project_usage(conn: sqlite3.Connection, *, account_id: int, consumer_key: str, project_key: str) -> Optional[dict]: + row = conn.execute( + """ + SELECT account_id, consumer_key, project_key, created_at + FROM account_project_usage + WHERE account_id = ? AND consumer_key = ? AND project_key = ? + """, + (account_id, consumer_key, project_key), + ).fetchone() + return dict(row) if row is not None else None + + +def append_claim_read_context( + conn: sqlite3.Connection, + *, + claim_token: str, + action: str, + payload: dict, +) -> None: + row = conn.execute( + """ + SELECT id, claim_read_context + FROM account_claim_logs + WHERE claim_token = ? AND action = ? + ORDER BY id DESC + LIMIT 1 + """, + (claim_token, action), + ).fetchone() + if row is None: + return + + merged_payload = {} + raw_context = row["claim_read_context"] + if raw_context: + try: + existing = json.loads(raw_context) + if isinstance(existing, dict): + merged_payload.update(existing) + except Exception: + pass + merged_payload.update(payload) + + conn.execute( + "UPDATE account_claim_logs SET claim_read_context = ? WHERE id = ?", + (json.dumps(merged_payload, ensure_ascii=False), row["id"]), + ) + conn.commit() + + 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, @@ -266,7 +492,6 @@ 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/services/external_api.py b/outlook_web/services/external_api.py index a2e9a0d1..d2f55c33 100644 --- a/outlook_web/services/external_api.py +++ b/outlook_web/services/external_api.py @@ -14,6 +14,7 @@ from outlook_web.services import graph as graph_service from outlook_web.services import imap as imap_service from outlook_web.services.imap_generic import get_email_detail_imap_generic_result, get_emails_imap_generic +from outlook_web.services import pool as pool_service from outlook_web.services.verification_extractor import extract_email_text, extract_verification_info_with_options # Outlook IMAP 回退服务器(保持与内部接口一致) @@ -79,6 +80,16 @@ class AccountAccessForbiddenError(ExternalApiError): status = 403 +class ClaimContextMismatchError(ExternalApiError): + code = "CLAIM_CONTEXT_MISMATCH" + status = 400 + + +class ClaimTokenNotFoundError(ExternalApiError): + code = "CLAIM_TOKEN_NOT_FOUND" + status = 404 + + def ok(data: Any = None, *, message: str = "success") -> Dict[str, Any]: return {"success": True, "code": "OK", "message": message, "data": data} @@ -157,6 +168,88 @@ def ensure_external_email_access(email_addr: str) -> None: ) +def _normalize_email_addr(email_addr: str | None) -> str: + return str(email_addr or "").strip().lower() + + +def resolve_claim_context(*, claim_token: str, email_addr: str | None = None) -> Dict[str, Any]: + try: + claim_context = pool_service.get_claim_context(claim_token=claim_token) + except pool_service.PoolServiceError as exc: + error_code = str(exc.error_code or "").strip().lower() + if error_code == "claim_token_not_found": + raise ClaimTokenNotFoundError(str(exc)) from exc + raise InvalidParamError(str(exc)) from exc + + claim_email = _normalize_email_addr(claim_context.get("email")) + if not claim_email: + raise ClaimTokenNotFoundError("claim_token 对应的邮箱上下文不存在") + ensure_external_email_access(claim_email) + + requested_email = _normalize_email_addr(email_addr) + if requested_email and requested_email != claim_email: + raise ClaimContextMismatchError( + "claim_token 与 email 不匹配", + data={"email": requested_email, "claim_email": claim_email}, + ) + + return { + "account_id": claim_context.get("account_id"), + "claim_token": claim_context.get("claim_token") or str(claim_token or "").strip(), + "email": claim_email, + "email_domain": claim_context.get("email_domain") or "", + "provider": claim_context.get("provider") or "", + "consumer_key": claim_context.get("consumer_key") or "", + "project_key": claim_context.get("project_key") or "", + "caller_id": claim_context.get("caller_id") or "", + "task_id": claim_context.get("task_id") or "", + "claimed_at": claim_context.get("claimed_at") or "", + "pool_status": claim_context.get("pool_status") or "", + } + + +def resolve_external_mail_scope(*, email_addr: str | None = None, claim_token: str | None = None) -> Dict[str, Any]: + normalized_claim_token = str(claim_token or "").strip() + normalized_email = _normalize_email_addr(email_addr) + + if normalized_claim_token: + claim_context = resolve_claim_context(claim_token=normalized_claim_token, email_addr=normalized_email or None) + return { + "email": claim_context["email"], + "claim_context": claim_context, + "claimed_at": claim_context.get("claimed_at") or "", + } + + if not normalized_email: + raise InvalidParamError("email 参数无效") + ensure_external_email_access(normalized_email) + return {"email": normalized_email, "claim_context": None, "claimed_at": ""} + + +def claimed_at_to_timestamp(claimed_at: str | None) -> Optional[int]: + dt = _parse_datetime(str(claimed_at or "")) + if not dt: + return None + try: + return int(dt.timestamp()) + except Exception: + return None + + +def record_claim_read_context(*, claim_token: str | None, action: str, payload: Dict[str, Any]) -> None: + normalized_token = str(claim_token or "").strip() + if not normalized_token: + return + try: + pool_service.append_claim_read_context( + claim_token=normalized_token, + action=action, + payload=payload, + ) + except Exception: + pass + + def _build_message_summary(email_addr: str, item: Dict[str, Any], *, method: str) -> Dict[str, Any]: raw_from = item.get("from") if isinstance(raw_from, dict): @@ -504,6 +597,7 @@ def filter_messages( from_contains: str = "", subject_contains: str = "", since_minutes: Optional[int] = None, + baseline_timestamp: Optional[int] = None, ) -> List[Dict[str, Any]]: from_contains = (from_contains or "").strip().lower() subject_contains = (subject_contains or "").strip().lower() @@ -531,6 +625,13 @@ def filter_messages( if dt and dt < since_dt: continue + if baseline_timestamp is not None: + try: + if int(e.get("timestamp") or 0) < int(baseline_timestamp): + continue + except Exception: + continue + filtered.append(e) return filtered @@ -542,6 +643,7 @@ def get_latest_message_for_external( from_contains: str = "", subject_contains: str = "", since_minutes: Optional[int] = None, + baseline_timestamp: Optional[int] = None, ) -> Dict[str, Any]: emails = list_messages_for_external(email_addr=email_addr, folder=folder, skip=0, top=20)[0] filtered = filter_messages( @@ -549,6 +651,7 @@ def get_latest_message_for_external( from_contains=from_contains, subject_contains=subject_contains, since_minutes=since_minutes, + baseline_timestamp=baseline_timestamp, ) if not filtered: raise MailNotFoundError("未找到匹配邮件", data={"email": email_addr}) @@ -700,6 +803,8 @@ def get_verification_result( from_contains: str = "", subject_contains: str = "", since_minutes: Optional[int] = None, + baseline_timestamp: Optional[int] = None, + claim_token: str | None = None, code_regex: str | None = None, code_length: str | None = None, code_source: str = "all", @@ -710,6 +815,7 @@ def get_verification_result( from_contains=from_contains, subject_contains=subject_contains, since_minutes=since_minutes, + baseline_timestamp=baseline_timestamp, ) message_id = str(latest_summary.get("id") or "") method = str(latest_summary.get("method") or "") @@ -744,6 +850,8 @@ def get_verification_result( extracted["subject"] = detail.get("subject") or latest_summary.get("subject") or "" extracted["received_at"] = detail.get("created_at") or latest_summary.get("created_at") or "" extracted["method"] = detail.get("method") or method + if claim_token: + extracted["claim_token"] = claim_token return extracted @@ -756,6 +864,8 @@ def wait_for_message( from_contains: str = "", subject_contains: str = "", since_minutes: Optional[int] = None, + baseline_timestamp: Optional[int] = None, + claim_token: str | None = None, ) -> Dict[str, Any]: try: timeout_seconds = int(timeout_seconds) @@ -769,7 +879,7 @@ def wait_for_message( raise InvalidParamError("poll_interval 参数无效") # 记录进入等待接口时的时间戳,避免把请求开始前已存在的旧邮件误判成“新到达”。 - baseline_timestamp = int(time.time()) + effective_baseline = int(baseline_timestamp or 0) or int(time.time()) start = time.time() last_error: Optional[ExternalApiError] = None while True: @@ -780,8 +890,11 @@ def wait_for_message( from_contains=from_contains, subject_contains=subject_contains, since_minutes=since_minutes, + baseline_timestamp=effective_baseline, ) - if int(latest_message.get("timestamp") or 0) >= baseline_timestamp: + if int(latest_message.get("timestamp") or 0) >= effective_baseline: + if claim_token: + latest_message["claim_token"] = claim_token return latest_message except MailNotFoundError as exc: last_error = exc @@ -823,6 +936,8 @@ def create_probe( from_contains: str = "", subject_contains: str = "", since_minutes: Optional[int] = None, + baseline_timestamp: Optional[int] = None, + claim_token: str | None = None, ) -> Dict[str, Any]: """ 创建一个异步探测请求,后台 worker 会定期轮询直到匹配或超时。 @@ -859,7 +974,7 @@ def create_probe( int(timeout_seconds), int(poll_interval), expires_at.isoformat(), - now.isoformat(), + (datetime.fromtimestamp(int(baseline_timestamp), timezone.utc).isoformat() if baseline_timestamp else now.isoformat()), now.isoformat(), ), ) @@ -868,6 +983,7 @@ def create_probe( return { "probe_id": probe_id, "status": "pending", + "claim_token": str(claim_token or "").strip(), "expires_at": expires_at.isoformat().replace("+00:00", "Z"), "poll_url": f"/api/external/probe/{probe_id}", } diff --git a/outlook_web/services/pool.py b/outlook_web/services/pool.py index 36b5eac0..d81f701d 100644 --- a/outlook_web/services/pool.py +++ b/outlook_web/services/pool.py @@ -1,11 +1,11 @@ """ -邮箱池服务层(PRD-00009 MT-1) +邮箱池服务层。 职责: -- 输入校验(caller_id / task_id / lease_seconds / result / detail 长度) -- 读取 settings(在 Flask app_context 下用 get_db,或直接接受 conn) -- 调用 repositories/pool.py 的原子操作 -- 将 repository 层的异常转换为业务错误码 +- 输入校验(caller_id / task_id / project_key / result / detail 等) +- 读取 settings 并驱动仓储层的原子操作 +- 统一 provider / email_domain 规则校验 +- 提供 claim 上下文读取能力,供外部读信链路复用 """ from __future__ import annotations @@ -14,9 +14,15 @@ from outlook_web.db import create_sqlite_connection from outlook_web.repositories import pool as pool_repo +from outlook_web.services.providers import ( + KNOWN_PROVIDER_KEYS, + normalize_email_domain, + provider_supports_email_domain, +) CALLER_ID_MAX_LEN = 64 TASK_ID_MAX_LEN = 128 +PROJECT_KEY_MAX_LEN = 128 REASON_MAX_LEN = 256 DETAIL_MAX_LEN = 512 @@ -32,18 +38,38 @@ def __init__(self, message: str, error_code: str, http_status: int = 400): self.http_status = http_status -def _validate_caller_id(caller_id: str) -> None: - if not caller_id or not caller_id.strip(): +def _validate_consumer_key(consumer_key: str) -> str: + normalized = str(consumer_key or "").strip() + if not normalized: + raise PoolServiceError("consumer_key 不能为空", "consumer_key_empty", http_status=403) + return normalized + + +def _validate_project_key(project_key: str) -> str: + normalized = str(project_key or "").strip() + if not normalized: + raise PoolServiceError("project_key 不能为空", "project_key_empty") + if len(normalized) > PROJECT_KEY_MAX_LEN: + raise PoolServiceError(f"project_key 超过最大长度 {PROJECT_KEY_MAX_LEN}", "project_key_too_long") + return normalized + + +def _validate_caller_id(caller_id: str) -> str: + normalized = str(caller_id or "").strip() + if not normalized: raise PoolServiceError("caller_id 不能为空", "caller_id_empty") - if len(caller_id) > CALLER_ID_MAX_LEN: + if len(normalized) > CALLER_ID_MAX_LEN: raise PoolServiceError(f"caller_id 超过最大长度 {CALLER_ID_MAX_LEN}", "caller_id_too_long") + return normalized -def _validate_task_id(task_id: str) -> None: - if not task_id or not task_id.strip(): +def _validate_task_id(task_id: str) -> str: + normalized = str(task_id or "").strip() + if not normalized: raise PoolServiceError("task_id 不能为空", "task_id_empty") - if len(task_id) > TASK_ID_MAX_LEN: + if len(normalized) > TASK_ID_MAX_LEN: raise PoolServiceError(f"task_id 超过最大长度 {TASK_ID_MAX_LEN}", "task_id_too_long") + return normalized def _validate_lease_seconds(lease_seconds: int, max_lease: int = 3600) -> None: @@ -53,8 +79,25 @@ def _validate_lease_seconds(lease_seconds: int, max_lease: int = 3600) -> None: raise PoolServiceError(f"lease_seconds 不能超过 {max_lease} 秒", "lease_seconds_too_large") +def _normalize_provider(provider: Optional[str]) -> str: + normalized = str(provider or "").strip().lower() + if normalized and normalized not in KNOWN_PROVIDER_KEYS: + raise PoolServiceError("provider 参数无效", "invalid_provider") + return normalized + + +def _normalize_email_domain(provider: str, email_domain: Optional[str]) -> str: + normalized_domain = normalize_email_domain(email_domain) + if not normalized_domain: + return "" + if not provider: + raise PoolServiceError("email_domain 需要配合 provider 使用", "email_domain_requires_provider") + if not provider_supports_email_domain(provider, normalized_domain): + raise PoolServiceError("provider 与 email_domain 不匹配", "provider_email_domain_mismatch") + return normalized_domain + + def _read_settings_via_conn(conn) -> dict: - """在独立连接场景下直接从 settings 表读取池相关配置。""" rows = conn.execute( "SELECT key, value FROM settings WHERE key IN (?, ?)", ("pool_cooldown_seconds", "pool_default_lease_seconds"), @@ -68,14 +111,64 @@ def _read_settings_via_conn(conn) -> dict: return result +def _load_claim_row(conn, account_id: int): + return conn.execute( + """ + SELECT id, claim_token, claimed_by, pool_status + FROM accounts + WHERE id = ? + """, + (account_id,), + ).fetchone() + + +def _validate_claim_record( + *, + row, + claim_token: str, + consumer_key: str, + project_key: str, + caller_id: str, + task_id: str, + operation: str, +) -> None: + if row is None: + raise PoolServiceError("账号不存在", "account_not_found", http_status=400) + if row["pool_status"] != "claimed": + raise PoolServiceError( + f"账号当前状态为 '{row['pool_status']}',无法 {operation}", + "not_claimed", + http_status=409, + ) + if row["claim_token"] != claim_token: + raise PoolServiceError("claim_token 不匹配", "token_mismatch", http_status=403) + + claimed_context = pool_repo.parse_claimed_by(row["claimed_by"]) + if claimed_context["consumer_key"] and claimed_context["consumer_key"] != consumer_key: + raise PoolServiceError("consumer_key 与领取记录不一致", "consumer_mismatch", http_status=403) + if claimed_context["project_key"] and claimed_context["project_key"] != project_key: + raise PoolServiceError("project_key 与领取记录不一致", "project_mismatch", http_status=403) + if claimed_context["caller_id"] != caller_id: + raise PoolServiceError("caller_id 与领取记录不一致", "caller_mismatch", http_status=403) + if claimed_context["task_id"] != task_id: + raise PoolServiceError("task_id 与领取记录不一致", "task_mismatch", http_status=403) + + def claim_random( *, + consumer_key: str, + project_key: str, caller_id: str, task_id: str, provider: Optional[str] = None, + email_domain: Optional[str] = None, ) -> dict: - _validate_caller_id(caller_id) - _validate_task_id(task_id) + consumer_key = _validate_consumer_key(consumer_key) + project_key = _validate_project_key(project_key) + caller_id = _validate_caller_id(caller_id) + task_id = _validate_task_id(task_id) + provider_key = _normalize_provider(provider) + normalized_domain = _normalize_email_domain(provider_key, email_domain) conn = create_sqlite_connection() try: @@ -85,10 +178,13 @@ def claim_random( account = pool_repo.claim_atomic( conn, + consumer_key=consumer_key, + project_key=project_key, caller_id=caller_id, task_id=task_id, lease_seconds=default_lease, - provider=provider, + provider=provider_key or None, + email_domain=normalized_domain or None, ) if account is None: raise PoolServiceError("池中没有符合条件的可用邮箱", "no_available_account", http_status=200) @@ -99,51 +195,54 @@ def claim_random( def release_claim( *, + consumer_key: str, + project_key: str, account_id: int, claim_token: str, caller_id: str, task_id: str, reason: Optional[str] = None, ) -> None: - """释放已领取的邮箱账号(不计入成功/失败统计,直接回 available)。""" - _validate_caller_id(caller_id) - _validate_task_id(task_id) - if not claim_token or not claim_token.strip(): + consumer_key = _validate_consumer_key(consumer_key) + project_key = _validate_project_key(project_key) + caller_id = _validate_caller_id(caller_id) + task_id = _validate_task_id(task_id) + normalized_token = str(claim_token or "").strip() + if not normalized_token: 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") conn = create_sqlite_connection() try: - row = conn.execute( - "SELECT id, claim_token, claimed_by, pool_status FROM accounts WHERE id = ?", - (account_id,), - ).fetchone() - if row is None: - raise PoolServiceError("账号不存在", "account_not_found", http_status=400) - if row["pool_status"] != "claimed": - raise PoolServiceError( - f"账号当前状态为 '{row['pool_status']}',无法 release", - "not_claimed", - http_status=409, - ) - if row["claim_token"] != claim_token: - 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( - "caller_id 或 task_id 与领取记录不一致", - "caller_mismatch", - http_status=403, - ) - - pool_repo.release(conn, account_id, claim_token, caller_id, task_id, reason) + row = _load_claim_row(conn, account_id) + _validate_claim_record( + row=row, + claim_token=normalized_token, + consumer_key=consumer_key, + project_key=project_key, + caller_id=caller_id, + task_id=task_id, + operation="release", + ) + pool_repo.release( + conn, + account_id=account_id, + claim_token=normalized_token, + consumer_key=consumer_key, + project_key=project_key, + caller_id=caller_id, + task_id=task_id, + reason=reason, + ) finally: conn.close() def complete_claim( *, + consumer_key: str, + project_key: str, account_id: int, claim_token: str, caller_id: str, @@ -151,55 +250,78 @@ def complete_claim( result: str, detail: Optional[str] = None, ) -> str: - """ - 标记领取结果并驱动状态机流转。 - - 返回账号的新 pool_status。 - """ - _validate_caller_id(caller_id) - _validate_task_id(task_id) - if not claim_token or not claim_token.strip(): + consumer_key = _validate_consumer_key(consumer_key) + project_key = _validate_project_key(project_key) + caller_id = _validate_caller_id(caller_id) + task_id = _validate_task_id(task_id) + normalized_token = str(claim_token or "").strip() + if not normalized_token: raise PoolServiceError("claim_token 不能为空", "claim_token_empty") if result not in VALID_RESULTS: - raise PoolServiceError( - f"result 必须是 {sorted(VALID_RESULTS)} 之一", - "invalid_result", - ) + raise PoolServiceError(f"result 必须是 {sorted(VALID_RESULTS)} 之一", "invalid_result") if detail and len(detail) > DETAIL_MAX_LEN: raise PoolServiceError(f"detail 超过最大长度 {DETAIL_MAX_LEN}", "detail_too_long") conn = create_sqlite_connection() try: - row = conn.execute( - "SELECT id, claim_token, claimed_by, pool_status FROM accounts WHERE id = ?", - (account_id,), - ).fetchone() - if row is None: - raise PoolServiceError("账号不存在", "account_not_found", http_status=400) - if row["pool_status"] != "claimed": - raise PoolServiceError( - f"账号当前状态为 '{row['pool_status']}',无法 complete", - "not_claimed", - http_status=409, - ) - if row["claim_token"] != claim_token: - 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( - "caller_id 或 task_id 与领取记录不一致", - "caller_mismatch", - http_status=403, - ) - - new_status = pool_repo.complete(conn, account_id, claim_token, caller_id, task_id, result, detail) - return new_status + row = _load_claim_row(conn, account_id) + _validate_claim_record( + row=row, + claim_token=normalized_token, + consumer_key=consumer_key, + project_key=project_key, + caller_id=caller_id, + task_id=task_id, + operation="complete", + ) + return pool_repo.complete( + conn, + account_id=account_id, + claim_token=normalized_token, + consumer_key=consumer_key, + project_key=project_key, + caller_id=caller_id, + task_id=task_id, + result=result, + detail=detail, + ) + finally: + conn.close() + + +def get_claim_context(*, claim_token: str) -> dict: + normalized_token = str(claim_token or "").strip() + if not normalized_token: + raise PoolServiceError("claim_token 不能为空", "claim_token_empty") + + conn = create_sqlite_connection() + try: + claim_context = pool_repo.get_claim_context_by_token(conn, normalized_token) + if claim_context is None: + raise PoolServiceError("claim_token 不存在或已失效", "claim_token_not_found", http_status=404) + return claim_context + finally: + conn.close() + + +def append_claim_read_context(*, claim_token: str, action: str, payload: dict) -> None: + normalized_token = str(claim_token or "").strip() + if not normalized_token: + return + + conn = create_sqlite_connection() + try: + pool_repo.append_claim_read_context( + conn, + claim_token=normalized_token, + action=action, + payload=payload, + ) finally: conn.close() def get_pool_stats() -> dict: - """返回池状态统计(不修改任何数据)。""" conn = create_sqlite_connection() try: return pool_repo.get_stats(conn) diff --git a/outlook_web/services/providers.py b/outlook_web/services/providers.py index 5dee91e8..fc7b3949 100644 --- a/outlook_web/services/providers.py +++ b/outlook_web/services/providers.py @@ -3,9 +3,8 @@ from typing import Any, Dict, List, Optional # 对齐:PRD-00005 / FD-00005 / TDD-00005 / PRD-00006 / FD-00006 -# 职责:集中维护“邮箱提供商”元数据与 IMAP 文件夹映射,避免前后端重复维护默认 host/port 与 folder 兼容策略。 +# 职责:集中维护邮箱提供商元数据、邮箱域名归一化规则,以及 provider/domain 一致性校验。 -# 邮箱提供商配置(用于前端选择与默认 IMAP host/port) MAIL_PROVIDERS: Dict[str, Dict[str, Any]] = { "outlook": { "label": "Outlook", @@ -65,33 +64,24 @@ }, } -# FD-00006: 域名 → provider 反向映射(用于 auto 模式域名推断) DOMAIN_PROVIDER_MAP: Dict[str, str] = { - # Gmail "gmail.com": "gmail", "googlemail.com": "gmail", - # QQ "qq.com": "qq", "foxmail.com": "qq", - # 163 "163.com": "163", - # 126 "126.com": "126", - # Yahoo "yahoo.com": "yahoo", "yahoo.co.jp": "yahoo", "yahoo.co.uk": "yahoo", - # 阿里云 "aliyun.com": "aliyun", "alimail.com": "aliyun", - # 微软(2段格式按 IMAP 兜底处理,OAuth 至少4段) "outlook.com": "outlook", "hotmail.com": "outlook", "live.com": "outlook", "live.cn": "outlook", } -# FD-00006: provider → 自动分组名映射 PROVIDER_GROUP_NAME: Dict[str, str] = { "outlook": "Outlook", "gmail": "Gmail", @@ -104,19 +94,59 @@ "gptmail": "临时邮箱", } -# FD-00006: 已知 provider key 集合(用于 3 段格式校验) -KNOWN_PROVIDER_KEYS: set = set(MAIL_PROVIDERS.keys()) +KNOWN_PROVIDER_KEYS: set[str] = set(MAIL_PROVIDERS.keys()) +PROVIDER_FAMILY_DOMAINS: Dict[str, set[str]] = {} +for domain_name, provider_key in DOMAIN_PROVIDER_MAP.items(): + PROVIDER_FAMILY_DOMAINS.setdefault(provider_key, set()).add(domain_name) -def infer_provider_from_email(email: str) -> Optional[str]: - """从邮箱地址推断 provider。返回 provider key 或 None。""" - if not email or "@" not in email: + +def normalize_email_domain(value: str | None) -> str: + text = str(value or "").strip().lower() + if not text: + return "" + if "@" in text: + text = text.rsplit("@", 1)[-1] + return text.strip().strip(".") + + +def extract_email_domain(email: str | None) -> str: + text = str(email or "").strip().lower() + if "@" not in text: + return "" + return normalize_email_domain(text.rsplit("@", 1)[-1]) + + +def infer_provider_from_email(email: str | None) -> Optional[str]: + domain = extract_email_domain(email) + if not domain: return None - domain = email.rsplit("@", 1)[-1].strip().lower() return DOMAIN_PROVIDER_MAP.get(domain) -# provider -> 逻辑文件夹名(inbox/junkemail/deleteditems)-> 候选 IMAP 文件夹名列表 +def infer_provider_from_domain(domain: str | None) -> Optional[str]: + normalized = normalize_email_domain(domain) + if not normalized: + return None + return DOMAIN_PROVIDER_MAP.get(normalized) + + +def provider_supports_email_domain(provider: str | None, email_domain: str | None) -> bool: + provider_key = str(provider or "").strip().lower() + normalized_domain = normalize_email_domain(email_domain) + if not provider_key or not normalized_domain: + return False + inferred_provider = infer_provider_from_domain(normalized_domain) + if inferred_provider is not None: + return inferred_provider == provider_key + return provider_key == "custom" + + +def get_provider_domains(provider: str | None) -> set[str]: + provider_key = str(provider or "").strip().lower() + return set(PROVIDER_FAMILY_DOMAINS.get(provider_key, set())) + + PROVIDER_FOLDER_MAP: Dict[str, Dict[str, List[str]]] = { "gmail": { "inbox": ["INBOX"], @@ -147,20 +177,13 @@ def infer_provider_from_email(email: str) -> Optional[str]: def get_imap_folder_candidates(provider: str, folder: str) -> List[str]: - """ - 根据 provider 和逻辑文件夹名(inbox/junkemail/deleteditems), - 返回候选 IMAP 文件夹名列表(按优先级排序)。 - 不存在的 provider 退回 _default。 - """ provider_key = (provider or "").strip() or "_default" folder_key = (folder or "").strip().lower() or "inbox" - folder_map = PROVIDER_FOLDER_MAP.get(provider_key, PROVIDER_FOLDER_MAP["_default"]) return folder_map.get(folder_key, PROVIDER_FOLDER_MAP["_default"].get(folder_key, ["INBOX"])) def get_provider_list() -> List[Dict[str, Any]]: - """返回供前端展示的 provider 列表(auto 在最前,outlook 其次,custom 在后)""" result: List[Dict[str, Any]] = [ { "key": "auto", @@ -173,13 +196,13 @@ def get_provider_list() -> List[Dict[str, Any]]: for key in order: if key not in MAIL_PROVIDERS: continue - p = MAIL_PROVIDERS[key] + provider = MAIL_PROVIDERS[key] result.append( { "key": key, - "label": p.get("label", key), - "account_type": p.get("account_type", "imap" if key != "outlook" else "outlook"), - "note": p.get("note", ""), + "label": provider.get("label", key), + "account_type": provider.get("account_type", "imap" if key != "outlook" else "outlook"), + "note": provider.get("note", ""), } ) return result diff --git a/registration-mail-pool-api.en.md b/registration-mail-pool-api.en.md index 7ae2a6ad..22209ced 100644 --- a/registration-mail-pool-api.en.md +++ b/registration-mail-pool-api.en.md @@ -116,8 +116,8 @@ Time fields use ISO 8601, for example: 1. `GET /api/external/health` 2. `GET /api/external/capabilities` 3. `POST /api/external/pool/claim-random` -4. read the returned `email` -5. call `verification-code` / `verification-link` / `wait-message` +4. read the returned `email`, `provider`, `email_domain`, and `claim_token` +5. for pool-issued mail reads, prefer `claim_token` when calling `verification-code` / `verification-link` / `wait-message` 6. call `claim-complete` on success 7. call `claim-release` if the task is abandoned @@ -179,6 +179,7 @@ Important response fields: - `exists` - `account_type` - `provider` +- `email_domain` - `group_id` - `status` - `last_refresh_at` @@ -197,7 +198,8 @@ These parameters apply to most mail-reading endpoints: | Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `email` | string | Yes | mailbox address | +| `email` | string | Conditionally | mailbox address, required when `claim_token` is not provided | +| `claim_token` | string | Conditionally | preferred for pool-issued reads; resolves mailbox context from the active claim | | `folder` | string | No | `inbox` / `junkemail` / `deleteditems`, default `inbox` | | `skip` | integer | No | default `0` | | `top` | integer | No | range `1-50`, default `20` | @@ -207,8 +209,9 @@ These parameters apply to most mail-reading endpoints: Notes: -- if the mailbox came from the pool, use the `email` returned by `claim-random` -- the current external read API is email-based, not claim-based +- if the mailbox came from the pool, prefer `claim_token`; `email` remains available for non-pool flows +- when both `claim_token` and `email` are sent, they must refer to the same mailbox +- claim-scoped reads use the claim `claimed_at` timestamp as a hard baseline to block historical mail leakage --- @@ -322,19 +325,18 @@ Request body: | --- | --- | --- | --- | | `caller_id` | string | Yes | caller instance, node, or worker identity | | `task_id` | string | Yes | unique task ID | +| `project_key` | string | Yes | business project identifier under the current authenticated consumer | | `provider` | string | No | provider filter, for example `outlook` | - -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 +| `email_domain` | string | No | exact normalized domain filter, for example `hotmail.com` | Success response fields: - `account_id` - `email` +- `provider` +- `email_domain` - `claim_token` +- `claimed_at` - `lease_expires_at` When no mailbox is available, the current implementation returns: @@ -351,6 +353,7 @@ Request body: | --- | --- | --- | --- | | `account_id` | integer | Yes | account ID returned by the claim operation | | `claim_token` | string | Yes | token returned by the claim operation | +| `project_key` | string | Yes | must exactly match the original claim | | `caller_id` | string | Yes | must exactly match the claim request | | `task_id` | string | Yes | must exactly match the claim request | | `reason` | string | No | release reason | @@ -363,6 +366,7 @@ Request body: | --- | --- | --- | --- | | `account_id` | integer | Yes | account ID returned by the claim operation | | `claim_token` | string | Yes | token returned by the claim operation | +| `project_key` | string | Yes | must exactly match the original claim | | `caller_id` | string | Yes | must exactly match the claim request | | `task_id` | string | Yes | must exactly match the claim request | | `result` | string | Yes | result enum, see table below | @@ -372,17 +376,12 @@ Request body: | `result` | Meaning | Final `pool_status` | | --- | --- | --- | -| `success` | registration succeeded and the mailbox was consumed | `used` | +| `success` | registration succeeded and the mailbox enters short cooldown | `cooldown` | | `verification_timeout` | no verification code arrived in time | `cooldown` | | `provider_blocked` | provider-side block or restriction | `frozen` | | `credential_invalid` | invalid credentials | `retired` | | `network_error` | temporary network issue, safe to retry quickly | `available` | -Current implementation notes: - -- `success` marks the mailbox as globally `used` -- the current version does not support project-scoped reuse of the same mailbox - ### `GET /api/external/pool/stats` Purpose: return counts for each pool state. @@ -420,6 +419,7 @@ For `claim-release` and `claim-complete`, the following fields must exactly matc - `account_id` - `claim_token` +- `project_key` - `caller_id` - `task_id` @@ -427,6 +427,19 @@ For `claim-release` and `claim-complete`, the following fields must exactly matc The sync `wait-message` endpoint returns only a matching message that appears after the request begins. Older matching messages are not treated as new arrivals. +### Claim-Scoped Reads + +- when a pool integration already holds a `claim_token`, use that token as the authoritative read context +- `verification-code`, `verification-link`, and `wait-message` resolve mailbox context from `claim_token` +- the claim `claimed_at` timestamp is enforced as the read baseline, so older matching mail is ignored +- if both `claim_token` and `email` are provided, they must match exactly or the request fails with `CLAIM_CONTEXT_MISMATCH` + +### Compatibility Window And Legacy `used` + +- `project_key` is now required by the runtime contract; do not rely on `caller_id` or `task_id` as a project substitute +- if you still have legacy callers, upgrade them during the current rollout window before enforcing the new contract everywhere +- legacy mailboxes already stuck in global `used` remain frozen for manual review; they are not automatically remapped to project-scoped history + --- ## Common Error Codes @@ -449,6 +462,10 @@ The sync `wait-message` endpoint returns only a matching message that appears af | `UPSTREAM_READ_FAILED` | Graph / IMAP read failed | | `PROXY_ERROR` | proxy connection failed | | `NO_AVAILABLE_ACCOUNT` | no eligible mailbox is currently available in the pool | +| `PROJECT_KEY_EMPTY` | `project_key` is missing | +| `PROVIDER_EMAIL_DOMAIN_MISMATCH` | `provider` and `email_domain` do not belong to the same family | +| `CLAIM_TOKEN_NOT_FOUND` | `claim_token` is missing, expired, or no longer active | +| `CLAIM_CONTEXT_MISMATCH` | `claim_token` and `email` point to different mailboxes | | `TOKEN_MISMATCH` | `claim_token` does not match | | `CALLER_MISMATCH` | `caller_id` or `task_id` does not match the claim record | | `NOT_CLAIMED` | the mailbox is not currently in `claimed` state | diff --git a/tests/test_domain_provider_map.py b/tests/test_domain_provider_map.py index 9cb2fb20..d127672d 100644 --- a/tests/test_domain_provider_map.py +++ b/tests/test_domain_provider_map.py @@ -59,6 +59,20 @@ def test_infer_invalid_input(self): self.assertIsNone(infer_provider_from_email(None)) self.assertIsNone(infer_provider_from_email("no-at-sign")) + def test_extract_and_normalize_email_domain(self): + from outlook_web.services.providers import extract_email_domain, normalize_email_domain + + self.assertEqual(extract_email_domain("User@HotMail.COM"), "hotmail.com") + self.assertEqual(normalize_email_domain(" HotMail.COM "), "hotmail.com") + self.assertEqual(normalize_email_domain("user@live.com"), "live.com") + + def test_provider_supports_email_domain(self): + from outlook_web.services.providers import provider_supports_email_domain + + self.assertTrue(provider_supports_email_domain("outlook", "hotmail.com")) + self.assertTrue(provider_supports_email_domain("outlook", "HotMail.COM")) + self.assertFalse(provider_supports_email_domain("gmail", "hotmail.com")) + def test_known_provider_keys(self): from outlook_web.services.providers import KNOWN_PROVIDER_KEYS, MAIL_PROVIDERS diff --git a/tests/test_external_api.py b/tests/test_external_api.py index 656c094e..28a3b5b5 100644 --- a/tests/test_external_api.py +++ b/tests/test_external_api.py @@ -69,6 +69,52 @@ def _insert_outlook_account(self, email_addr: str | None = None) -> str: db.commit() return email_addr + def _insert_claimed_pool_account( + self, + *, + email_addr: str | None = None, + project_key: str = "register", + consumer_key: str = "legacy:settings.external_api_key", + caller_id: str = "pool-worker", + task_id: str = "pool-task", + claim_token: str = "clm_test_token", + claimed_at: str | None = None, + ) -> str: + email_addr = email_addr or f"{uuid.uuid4().hex}@extapi.test" + claimed_at = claimed_at or self._utc_iso() + claimed_by = f"{consumer_key}||{project_key}||{caller_id}||{task_id}" + with self.app.app_context(): + from outlook_web.db import get_db + + db = get_db() + db.execute( + """ + INSERT INTO accounts ( + email, email_domain, password, client_id, refresh_token, group_id, + status, account_type, provider, pool_status, claimed_by, claimed_at, + lease_expires_at, claim_token + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'claimed', ?, ?, ?, ?) + """, + ( + email_addr, + email_addr.rsplit("@", 1)[-1].lower(), + "pw", + "cid-test", + "rt-test", + 1, + "active", + "outlook", + "outlook", + claimed_by, + claimed_at, + self._utc_iso(minutes_delta=10), + claim_token, + ), + ) + db.commit() + return email_addr + def _insert_imap_account(self, email_addr: str | None = None) -> str: email_addr = email_addr or f"{uuid.uuid4().hex}@extapi.test" with self.app.app_context(): @@ -561,6 +607,65 @@ def test_external_verification_link_defaults_to_recent_10_minutes(self, mock_get self.assertEqual(resp.status_code, 404) self.assertEqual(resp.get_json().get("code"), "MAIL_NOT_FOUND") + @patch("outlook_web.services.graph.get_email_raw_graph") + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_external_verification_code_supports_claim_token_baseline( + self, + mock_get_emails_graph, + mock_get_email_detail_graph, + mock_get_email_raw_graph, + ): + claimed_at = "2026-03-08T12:00:00Z" + claim_email = self._insert_claimed_pool_account( + email_addr=f"{uuid.uuid4().hex}@extapi.test", + claim_token="clm_claim_read_001", + claimed_at=claimed_at, + ) + self._set_external_api_key("abc123") + mock_get_emails_graph.return_value = { + "success": True, + "emails": [ + self._graph_email(message_id="old-msg", received_at="2026-03-08T11:59:00Z"), + self._graph_email(message_id="new-msg", received_at="2026-03-08T12:01:00Z"), + ], + } + mock_get_email_detail_graph.return_value = self._graph_detail( + message_id="new-msg", + body_text="Your code is 123456", + received_at="2026-03-08T12:01:00Z", + ) + mock_get_email_raw_graph.return_value = "RAW MIME CONTENT" + + client = self.app.test_client() + resp = client.get( + "/api/external/verification-code?claim_token=clm_claim_read_001", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertEqual(data.get("data", {}).get("email"), claim_email) + self.assertEqual(data.get("data", {}).get("matched_email_id"), "new-msg") + self.assertEqual(data.get("data", {}).get("claim_token"), "clm_claim_read_001") + + def test_external_verification_code_rejects_claim_email_mismatch(self): + self._insert_claimed_pool_account( + email_addr=f"{uuid.uuid4().hex}@extapi.test", + claim_token="clm_claim_mismatch_001", + ) + self._set_external_api_key("abc123") + client = self.app.test_client() + + resp = client.get( + "/api/external/verification-code?claim_token=clm_claim_mismatch_001&email=other@extapi.test", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 400) + self.assertEqual(resp.get_json().get("code"), "CLAIM_CONTEXT_MISMATCH") + @patch("outlook_web.services.external_api.time.sleep") @patch("outlook_web.services.external_api.time.time") @patch("outlook_web.services.external_api.get_latest_message_for_external") @@ -987,6 +1092,34 @@ def test_wait_message_http_returns_400_for_missing_email(self): self.assertEqual(resp.status_code, 400) self.assertEqual(resp.get_json().get("code"), "INVALID_PARAM") + @patch("outlook_web.services.external_api.time.sleep") + @patch("outlook_web.services.external_api.time.time") + @patch("outlook_web.services.graph.get_emails_graph") + def test_wait_message_http_supports_claim_token_baseline(self, mock_get_emails_graph, mock_time, mock_sleep): + self._insert_claimed_pool_account( + email_addr=f"{uuid.uuid4().hex}@extapi.test", + claim_token="clm_wait_claim_001", + claimed_at="2026-03-08T12:00:00Z", + ) + self._set_external_api_key("abc123") + mock_time.side_effect = [2000000000, 2000000000, 2000000000] + mock_get_emails_graph.side_effect = [ + {"success": True, "emails": [self._graph_email(message_id="old-msg", received_at="2026-03-08T11:59:00Z")]}, + {"success": True, "emails": [self._graph_email(message_id="new-msg", received_at="2026-03-08T12:01:00Z")]}, + ] + + client = self.app.test_client() + resp = client.get( + "/api/external/wait-message?claim_token=clm_wait_claim_001&timeout_seconds=30&poll_interval=5", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertEqual(data.get("data", {}).get("id"), "new-msg") + self.assertEqual(data.get("data", {}).get("claim_token"), "clm_wait_claim_001") + @patch("outlook_web.services.external_api.wait_for_message") def test_wait_message_http_unexpected_error_logs_audit(self, mock_wait_for_message): """wait-message 未预期异常也应写 external_api 审计日志""" diff --git a/tests/test_pool_flow_suite.py b/tests/test_pool_flow_suite.py index fca010d5..2fe32acf 100644 --- a/tests/test_pool_flow_suite.py +++ b/tests/test_pool_flow_suite.py @@ -23,6 +23,7 @@ def setUp(self): db = get_db() db.execute("DELETE FROM external_api_keys") db.execute("DELETE FROM external_api_rate_limits") + db.execute("DELETE FROM account_project_usage") db.commit() settings_repo.set_setting("external_api_key", "abc123") settings_repo.set_setting("pool_external_enabled", "true") @@ -38,40 +39,53 @@ def setUp(self): def _auth_headers(): return {"X-API-Key": "abc123"} - def _make_pool_account(self, *, provider: str = "outlook", pool_status: str = "available") -> dict: + def _make_pool_account(self, *, email_domain: str = "outlook.com", provider: str = "outlook", pool_status: str = "available") -> dict: conn = self.create_conn() try: - email_addr = f"flow_{uuid.uuid4().hex}@poolflow.test" + email_addr = f"flow_{uuid.uuid4().hex}@{email_domain}" conn.execute( """ INSERT INTO accounts ( - email, client_id, refresh_token, status, + email, email_domain, client_id, refresh_token, status, account_type, provider, group_id, pool_status ) - VALUES (?, 'test_client', 'test_token', 'active', 'outlook', ?, 1, ?) + VALUES (?, ?, 'test_client', 'test_token', 'active', 'outlook', ?, 1, ?) """, - (email_addr, provider, pool_status), + (email_addr, email_domain, provider, pool_status), ) conn.commit() row = conn.execute( - "SELECT id, email, pool_status, provider FROM accounts WHERE email = ?", + "SELECT id, email, email_domain, 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( + def _claim(self, *, task_id: str, project_key: str, provider: str = "outlook", email_domain: str | None = None): + payload = { + "caller_id": "suite_bot", + "task_id": task_id, + "project_key": project_key, + "provider": provider, + } + if email_domain is not None: + payload["email_domain"] = email_domain + return self.client.post( "/api/external/pool/claim-random", headers=self._auth_headers(), - json={"caller_id": "suite_bot", "task_id": "success_flow"}, + json=payload, ) + + def test_claim_complete_success_changes_status_to_cooldown(self): + self._make_pool_account() + + claim_resp = self._claim(task_id="success_flow", project_key="register") self.assertEqual(claim_resp.status_code, 200) claim_data = json.loads(claim_resp.data) self.assertTrue(claim_data["success"]) + self.assertEqual(claim_data["data"]["provider"], "outlook") + self.assertEqual(claim_data["data"]["email_domain"], "outlook.com") complete_resp = self.client.post( "/api/external/pool/claim-complete", @@ -81,6 +95,7 @@ def test_claim_complete_success_changes_status_to_used(self): "claim_token": claim_data["data"]["claim_token"], "caller_id": "suite_bot", "task_id": "success_flow", + "project_key": "register", "result": "success", "detail": "manual suite success", }, @@ -88,7 +103,9 @@ def test_claim_complete_success_changes_status_to_used(self): 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") + self.assertEqual(complete_data["data"]["pool_status"], "cooldown") + self.assertEqual(complete_data["data"]["provider"], "outlook") + self.assertEqual(complete_data["data"]["email_domain"], "outlook.com") conn = self.create_conn() try: @@ -96,20 +113,27 @@ def test_claim_complete_success_changes_status_to_used(self): "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["pool_status"], "cooldown") self.assertEqual(row["success_count"], 1) self.assertEqual(row["fail_count"], 0) + + usage = conn.execute( + """ + SELECT consumer_key, project_key + FROM account_project_usage + WHERE account_id = ? + """, + (claim_data["data"]["account_id"],), + ).fetchone() + self.assertIsNotNone(usage) + self.assertEqual(usage["project_key"], "register") finally: conn.close() def test_claim_complete_failure_changes_status_to_cooldown(self): self._make_pool_account() - claim_resp = self.client.post( - "/api/external/pool/claim-random", - headers=self._auth_headers(), - json={"caller_id": "suite_bot", "task_id": "cooldown_flow"}, - ) + claim_resp = self._claim(task_id="cooldown_flow", project_key="register") self.assertEqual(claim_resp.status_code, 200) claim_data = json.loads(claim_resp.data) self.assertTrue(claim_data["success"]) @@ -122,6 +146,7 @@ def test_claim_complete_failure_changes_status_to_cooldown(self): "claim_token": claim_data["data"]["claim_token"], "caller_id": "suite_bot", "task_id": "cooldown_flow", + "project_key": "register", "result": "verification_timeout", "detail": "manual suite timeout", }, @@ -131,84 +156,58 @@ def test_claim_complete_failure_changes_status_to_cooldown(self): 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_same_project_cannot_reclaim_after_success_but_other_project_can(self): + account = self._make_pool_account(email_domain="hotmail.com") - 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/external/pool/claim-random", - headers=self._auth_headers(), - 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}", - ) - ) + first_claim = self._claim(task_id="same_project_1", project_key="register", email_domain="hotmail.com") + self.assertEqual(first_claim.status_code, 200) + first_data = first_claim.get_json()["data"] + self.assertEqual(first_data["account_id"], account["id"]) - 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/external/pool/claim-release", - headers=self._auth_headers(), - 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") + complete_resp = self.client.post( + "/api/external/pool/claim-complete", + headers=self._auth_headers(), + json={ + "account_id": first_data["account_id"], + "claim_token": first_data["claim_token"], + "caller_id": "suite_bot", + "task_id": "same_project_1", + "project_key": "register", + "result": "success", + }, + ) + self.assertEqual(complete_resp.status_code, 200) 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"]) + conn.execute("UPDATE accounts SET pool_status = 'available' WHERE id = ?", (account["id"],)) + conn.commit() finally: conn.close() + second_claim = self._claim(task_id="same_project_2", project_key="register", email_domain="hotmail.com") + self.assertEqual(second_claim.status_code, 200) + second_data = second_claim.get_json() + self.assertFalse(second_data["success"]) + self.assertEqual(second_data["code"], "NO_AVAILABLE_ACCOUNT") + + other_project_claim = self._claim(task_id="other_project", project_key="login", email_domain="hotmail.com") + self.assertEqual(other_project_claim.status_code, 200) + other_project_data = other_project_claim.get_json() + self.assertTrue(other_project_data["success"]) + self.assertEqual(other_project_data["data"]["account_id"], account["id"]) + + def test_claim_random_filters_by_email_domain(self): + self._make_pool_account(email_domain="outlook.com") + hotmail_account = self._make_pool_account(email_domain="hotmail.com") + + claim_resp = self._claim(task_id="domain_filter", project_key="register", email_domain="HotMail.COM") + self.assertEqual(claim_resp.status_code, 200) + data = claim_resp.get_json() + self.assertTrue(data["success"]) + self.assertEqual(data["data"]["account_id"], hotmail_account["id"]) + self.assertEqual(data["data"]["email_domain"], "hotmail.com") + if __name__ == "__main__": unittest.main() 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 2725d796..38efbcf9 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" @@ -115,8 +115,8 @@ X-API-Key: YOUR_API_KEY 1. `GET /api/external/health` 2. `GET /api/external/capabilities` 3. `POST /api/external/pool/claim-random` -4. 从领取结果中拿到 `email` -5. 调用 `verification-code` / `verification-link` / `wait-message` +4. 从领取结果中拿到 `email`、`provider`、`email_domain`、`claim_token` +5. 如果是邮箱池领取出的邮箱,优先用 `claim_token` 调用 `verification-code` / `verification-link` / `wait-message` 6. 成功时调用 `claim-complete` 7. 中途放弃时调用 `claim-release` @@ -178,6 +178,7 @@ curl -X GET https://api.example.com/api/external/health \ - `exists` - `account_type` - `provider` +- `email_domain` - `group_id` - `status` - `last_refresh_at` @@ -196,7 +197,8 @@ curl -X GET https://api.example.com/api/external/health \ | 参数名 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | -| `email` | string | 是 | 邮箱地址 | +| `email` | string | 条件必填 | 不传 `claim_token` 时必须提供邮箱地址 | +| `claim_token` | string | 条件必填 | 邮箱池读信推荐参数,用于从 active claim 解析邮箱上下文 | | `folder` | string | 否 | `inbox` / `junkemail` / `deleteditems`,默认 `inbox` | | `skip` | integer | 否 | 默认 `0` | | `top` | integer | 否 | 范围 `1-50`,默认 `20` | @@ -206,8 +208,9 @@ curl -X GET https://api.example.com/api/external/health \ 说明: -- 如果你们是先从邮箱池领取邮箱,应直接使用 `claim-random` 返回的 `email` -- 当前外部读取接口是“按 `email` 读取”,不是“按 `claim_id` 读取” +- 如果邮箱来自邮箱池,推荐优先传 `claim_token`;纯邮箱直读仍可用于非池场景 +- 如果同时传 `claim_token` 和 `email`,两者必须指向同一个邮箱 +- claim 级读信会强制使用该 claim 的 `claimed_at` 作为时间基线,避免误读历史邮件 --- @@ -321,19 +324,18 @@ curl -X GET https://api.example.com/api/external/health \ | --- | --- | --- | --- | | `caller_id` | string | 是 | 调用方实例、节点或 worker 标识 | | `task_id` | string | 是 | 当前任务唯一 ID | +| `project_key` | string | 是 | 当前认证 consumer 下的业务项目标识 | | `provider` | string | 否 | 提供商筛选,例如 `outlook` | - -当前实现说明: - -- 当前池接口只支持按 `provider` 筛选 -- `outlook.com`、`hotmail.com`、`live.com`、`live.cn` 当前都归属于 `provider=outlook` -- 当前对外池接口不支持按域名、分组、标签进一步筛选 +| `email_domain` | string | 否 | 精确域名筛选,例如 `hotmail.com` | 成功返回字段: - `account_id` - `email` +- `provider` +- `email_domain` - `claim_token` +- `claimed_at` - `lease_expires_at` 无可用邮箱时,当前实现返回: @@ -350,6 +352,7 @@ curl -X GET https://api.example.com/api/external/health \ | --- | --- | --- | --- | | `account_id` | integer | 是 | 领取时返回的账号 ID | | `claim_token` | string | 是 | 领取时返回的令牌 | +| `project_key` | string | 是 | 必须与原始 claim 完全一致 | | `caller_id` | string | 是 | 必须与领取时一致 | | `task_id` | string | 是 | 必须与领取时一致 | | `reason` | string | 否 | 释放原因 | @@ -362,6 +365,7 @@ curl -X GET https://api.example.com/api/external/health \ | --- | --- | --- | --- | | `account_id` | integer | 是 | 领取时返回的账号 ID | | `claim_token` | string | 是 | 领取时返回的令牌 | +| `project_key` | string | 是 | 必须与原始 claim 完全一致 | | `caller_id` | string | 是 | 必须与领取时一致 | | `task_id` | string | 是 | 必须与领取时一致 | | `result` | string | 是 | 结果枚举,见下表 | @@ -371,17 +375,12 @@ curl -X GET https://api.example.com/api/external/health \ | `result` | 含义 | 回写后的 `pool_status` | | --- | --- | --- | -| `success` | 注册成功,账号被消耗 | `used` | +| `success` | 注册成功,账号进入短冷却 | `cooldown` | | `verification_timeout` | 长时间未收到验证码 | `cooldown` | | `provider_blocked` | 被提供商风控或限制 | `frozen` | | `credential_invalid` | 凭据失效 | `retired` | | `network_error` | 临时网络问题,可立即重试 | `available` | -当前实现说明: - -- `success` 会把邮箱全局标记为 `used` -- 当前版本没有按项目维度复用同一邮箱的状态模型 - ### `GET /api/external/pool/stats` 用途:返回池中各状态数量。 @@ -419,6 +418,7 @@ curl -X GET https://api.example.com/api/external/health \ - `account_id` - `claim_token` +- `project_key` - `caller_id` - `task_id` @@ -428,6 +428,19 @@ curl -X GET https://api.example.com/api/external/health \ 同步 `wait-message` 只会返回请求开始之后才出现的匹配邮件,不会把请求前就存在的旧邮件误判成新邮件。 +### Claim 级读信 + +- 如果已经持有邮箱池返回的 `claim_token`,应把它作为读信主上下文 +- `verification-code`、`verification-link`、`wait-message` 都支持用 `claim_token` 自动解析邮箱 +- claim 读信会强制使用 `claimed_at` 作为时间基线,旧邮件即使匹配也不会被返回 +- 如果同时传了 `claim_token` 和 `email`,两者不一致时会返回 `CLAIM_CONTEXT_MISMATCH` + +### 兼容窗口与 legacy `used` + +- 运行时契约已经要求必须传 `project_key`,不要再把项目名塞进 `caller_id` 或 `task_id` +- rollout 期间请尽快升级旧调用方;兼容窗口只作为迁移说明,不建议依赖默认兜底 +- 历史上已经进入全局 `used` 的邮箱不会自动迁移到项目级使用记录,保持冻结并等待人工核查 + --- ## 常见错误码 @@ -450,6 +463,10 @@ curl -X GET https://api.example.com/api/external/health \ | `UPSTREAM_READ_FAILED` | Graph / IMAP 读取失败 | | `PROXY_ERROR` | 代理连接失败 | | `NO_AVAILABLE_ACCOUNT` | 池中没有符合条件的可用邮箱 | +| `PROJECT_KEY_EMPTY` | 缺少 `project_key` | +| `PROVIDER_EMAIL_DOMAIN_MISMATCH` | `provider` 与 `email_domain` 不属于同一邮箱族 | +| `CLAIM_TOKEN_NOT_FOUND` | `claim_token` 不存在、已失效或不再 active | +| `CLAIM_CONTEXT_MISMATCH` | `claim_token` 与 `email` 指向了不同邮箱 | | `TOKEN_MISMATCH` | `claim_token` 不匹配 | | `CALLER_MISMATCH` | `caller_id` 或 `task_id` 与领取记录不一致 | | `NOT_CLAIMED` | 账号当前不在 `claimed` 状态 | From a6aef3d21e47483644fa22b549135e0c2e85d666 Mon Sep 17 00:00:00 2001 From: android-dev1 Date: Sat, 11 Apr 2026 14:27:11 +0800 Subject: [PATCH 2/8] perf: optimize email fetching with SSE streaming, channel cache, and concurrent IMAP - Skip Graph API for accounts without Mail.Read permission (check token scope) - Cache account channel (graph/imap) in memory with 1h TTL to avoid repeated probing - Add server-side email list cache with 2h TTL to eliminate redundant fetches - SSE streaming endpoint (/api/emails//stream) for progressive email rendering - Concurrent IMAP: try both outlook.live.com and outlook.office365.com simultaneously - Batch IMAP FETCH: single request for all messages instead of N sequential fetches - Gunicorn: switch to gthread worker with 8 threads for concurrent request handling - Fix cached method name matching (use includes() instead of strict equality) Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 6 +- outlook_web/controllers/emails.py | 310 +++++++++++++++++--------- outlook_web/routes/emails.py | 5 + outlook_web/services/channel_cache.py | 43 ++++ outlook_web/services/email_cache.py | 67 ++++++ outlook_web/services/graph.py | 21 ++ outlook_web/services/imap.py | 309 ++++++++++++++++++++++++- static/js/features/emails.js | 134 ++++++----- 8 files changed, 728 insertions(+), 167 deletions(-) create mode 100644 outlook_web/services/channel_cache.py create mode 100644 outlook_web/services/email_cache.py diff --git a/Dockerfile b/Dockerfile index 44b63dc6..bbfd9b03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,6 @@ EXPOSE 5000 # 健康检查 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD ["python","-c","import urllib.request as u; u.urlopen('http://localhost:5000/healthz', timeout=4).read()"] -# 启动应用(使用 Gunicorn,单 worker 避免 session 共享问题) -# 注意:禁用 --preload,避免在 master 进程中启动后台调度线程 -CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:5000", "--timeout", "120", "--access-logfile", "-", "web_outlook_app:app"] +# 启动应用(单 worker + 多线程:保持调度器单实例,同时支持并发请求处理) +# 瓶颈是网络 I/O(Graph API / IMAP),GIL 在 I/O 期间释放,线程并发有效 +CMD ["gunicorn", "-w", "1", "--threads", "8", "-b", "0.0.0.0:5000", "--timeout", "120", "--access-logfile", "-", "web_outlook_app:app"] diff --git a/outlook_web/controllers/emails.py b/outlook_web/controllers/emails.py index 5b3be0c2..f2c484f4 100644 --- a/outlook_web/controllers/emails.py +++ b/outlook_web/controllers/emails.py @@ -3,7 +3,9 @@ import logging from typing import Any -from flask import jsonify, request +import json + +from flask import Response, jsonify, request, stream_with_context from outlook_web import config from outlook_web.audit import log_audit @@ -18,6 +20,8 @@ from outlook_web.services import external_api as external_api_service from outlook_web.services import graph as graph_service from outlook_web.services import imap as imap_service +from outlook_web.services.channel_cache import get_cached_channel, set_cached_channel +from outlook_web.services.email_cache import get_cached_emails, set_cached_emails from outlook_web.services.imap_generic import ( get_email_detail_imap_generic_result, get_emails_imap_generic, @@ -116,6 +120,16 @@ def api_get_emails(email_addr: str) -> Any: ) return jsonify(result) + # 服务端缓存(2h TTL) + cached = get_cached_emails(email_addr, folder) + if cached: + return jsonify({ + "success": True, + "emails": cached["emails"], + "method": cached["method"] + " (cached)", + "has_more": cached["has_more"], + }) + # 获取分组代理设置 proxy_url = "" if account.get("group_id"): @@ -125,143 +139,126 @@ def api_get_emails(email_addr: str) -> Any: # 收集所有错误信息 all_errors = {} + graph_result = None + + # 通道缓存:已知走 IMAP 的账号直接跳过 Graph API + cached_channel = get_cached_channel(email_addr) + + # 1. 尝试 Graph API(缓存为 imap 时跳过) + if cached_channel != "imap": + graph_result = graph_service.get_emails_graph(account["client_id"], account["refresh_token"], folder, skip, top, proxy_url) + if graph_result.get("success"): + set_cached_channel(email_addr, "graph") + emails = graph_result.get("emails", []) + account_summary = compact_summary_service.update_summary_from_message_list( + int(account["id"]), + emails, + folder=folder, + ) + # 更新刷新时间,同时保存 Microsoft 可能返回的新 refresh_token(Token Rotation) + db = get_db() + new_rt = graph_result.get("new_refresh_token") + if new_rt and new_rt != account.get("refresh_token"): + from outlook_web.security.crypto import encrypt_data as _encrypt_data - # 1. 尝试 Graph API - graph_result = graph_service.get_emails_graph(account["client_id"], account["refresh_token"], folder, skip, top, proxy_url) - if graph_result.get("success"): - emails = graph_result.get("emails", []) - account_summary = compact_summary_service.update_summary_from_message_list( - int(account["id"]), - emails, - folder=folder, - ) - # 更新刷新时间,同时保存 Microsoft 可能返回的新 refresh_token(Token Rotation) - db = get_db() - new_rt = graph_result.get("new_refresh_token") - if new_rt and new_rt != account.get("refresh_token"): - from outlook_web.security.crypto import encrypt_data as _encrypt_data - - try: - db.execute( - "UPDATE accounts SET refresh_token = ?, last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE email = ?", - (_encrypt_data(new_rt), email_addr), - ) - except Exception: + try: + db.execute( + "UPDATE accounts SET refresh_token = ?, last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE email = ?", + (_encrypt_data(new_rt), email_addr), + ) + except Exception: + db.execute( + "UPDATE accounts SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE email = ?", + (email_addr,), + ) + else: db.execute( - "UPDATE accounts SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE email = ?", + """ + UPDATE accounts + SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE email = ? + """, (email_addr,), ) - else: - db.execute( - """ - UPDATE accounts - SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE email = ? - """, - (email_addr,), - ) - db.commit() + db.commit() + + # 格式化 Graph API 返回的数据 + formatted = [] + for e in emails: + formatted.append( + { + "id": e.get("id"), + "subject": e.get("subject", "无主题"), + "from": e.get("from", {}).get("emailAddress", {}).get("address", "未知"), + "date": e.get("receivedDateTime", ""), + "is_read": e.get("isRead", False), + "has_attachments": e.get("hasAttachments", False), + "body_preview": e.get("bodyPreview", ""), + } + ) - # 格式化 Graph API 返回的数据 - formatted = [] - for e in emails: - formatted.append( + return jsonify( { - "id": e.get("id"), - "subject": e.get("subject", "无主题"), - "from": e.get("from", {}).get("emailAddress", {}).get("address", "未知"), - "date": e.get("receivedDateTime", ""), - "is_read": e.get("isRead", False), - "has_attachments": e.get("hasAttachments", False), - "body_preview": e.get("bodyPreview", ""), + "success": True, + "emails": formatted, + "method": "Graph API", + "has_more": len(formatted) >= top, + "account_summary": account_summary, } ) + else: + graph_error = graph_result.get("error") + all_errors["graph"] = graph_error + + # 无 Mail.Read 权限 → 缓存为 imap,后续请求直接跳过 Graph + if graph_result.get("no_mail_permission"): + set_cached_channel(email_addr, "imap") + + # 如果是代理错误,不再回退 IMAP + if isinstance(graph_error, dict) and graph_error.get("type") in ( + "ProxyError", + "ConnectionError", + ): + return build_error_response( + "EMAIL_PROXY_CONNECTION_FAILED", + "代理连接失败,请检查分组代理设置", + message_en="Proxy connection failed. Please check the group proxy settings", + err_type="ProxyError", + status=502, + details=all_errors, + extra={"details": all_errors}, + ) - return jsonify( - { - "success": True, - "emails": formatted, - "method": "Graph API", - "has_more": len(formatted) >= top, - "account_summary": account_summary, - } - ) - else: - graph_error = graph_result.get("error") - all_errors["graph"] = graph_error - - # 如果是代理错误,不再回退 IMAP - if isinstance(graph_error, dict) and graph_error.get("type") in ( - "ProxyError", - "ConnectionError", - ): - return build_error_response( - "EMAIL_PROXY_CONNECTION_FAILED", - "代理连接失败,请检查分组代理设置", - message_en="Proxy connection failed. Please check the group proxy settings", - err_type="ProxyError", - status=502, - details=all_errors, - extra={"details": all_errors}, - ) - - imap_new_result = imap_service.get_emails_imap_with_server( - account["email"], - account["client_id"], - account["refresh_token"], - folder, - skip, - top, - IMAP_SERVER_NEW, - ) - if imap_new_result.get("success"): - account_summary = compact_summary_service.update_summary_from_message_list( - int(account["id"]), - imap_new_result.get("emails", []), - folder=folder, - ) - return jsonify( - { - "success": True, - "emails": imap_new_result.get("emails", []), - "method": "IMAP (New)", - "has_more": False, # IMAP 分页暂未完全实现 - "account_summary": account_summary, - } - ) - else: - all_errors["imap_new"] = imap_new_result.get("error") - - # 3. 尝试旧版 IMAP (outlook.office365.com) - imap_old_result = imap_service.get_emails_imap_with_server( + # 2. IMAP 回退:新旧服务器并发尝试 + imap_result = imap_service.get_emails_imap_concurrent( account["email"], account["client_id"], account["refresh_token"], folder, skip, top, - IMAP_SERVER_OLD, ) - if imap_old_result.get("success"): + if imap_result.get("success"): + set_cached_channel(email_addr, "imap") account_summary = compact_summary_service.update_summary_from_message_list( int(account["id"]), - imap_old_result.get("emails", []), + imap_result.get("emails", []), folder=folder, ) return jsonify( { "success": True, - "emails": imap_old_result.get("emails", []), - "method": "IMAP (Old)", + "emails": imap_result.get("emails", []), + "method": imap_result.get("method", "IMAP"), "has_more": False, "account_summary": account_summary, } ) else: - all_errors["imap_old"] = imap_old_result.get("error") + all_errors["imap"] = imap_result.get("error") # 所有方式均失败;若 Graph API 明确返回 token 过期,优先提示重新授权 - if graph_result.get("auth_expired"): + if graph_result and graph_result.get("auth_expired"): return build_error_response( "ACCOUNT_AUTH_EXPIRED", "账号授权已失效,请前往「刷新 Token」页面重新授权", @@ -281,6 +278,103 @@ def api_get_emails(email_addr: str) -> Any: ) +@login_required +def api_stream_emails(email_addr: str) -> Any: + """SSE 流式获取邮件:IMAP 每拉到一封就推一条 event,前端边收边渲染。""" + account = accounts_repo.get_account_by_email(email_addr) + if not account: + return build_error_response( + "ACCOUNT_NOT_FOUND", "账号不存在", + message_en="Account not found", err_type="NotFoundError", + status=404, details=f"email={email_addr}", + ) + + folder = request.args.get("folder", "inbox") + skip = int(request.args.get("skip", 0)) + top = int(request.args.get("top", 20)) + force = request.args.get("force", "").lower() in ("1", "true") + + account_type = (account.get("account_type") or "outlook").strip().lower() + + # 获取分组代理设置 + proxy_url = "" + if account.get("group_id"): + group = groups_repo.get_group_by_id(account["group_id"]) + if group: + proxy_url = group.get("proxy_url", "") or "" + + def _sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + def generate(): + # --- 服务端缓存(2h TTL)--- + if not force: + cached = get_cached_emails(email_addr, folder) + if cached: + for e in cached["emails"]: + yield _sse("email", e) + yield _sse("done", { + "method": cached["method"] + " (cached)", + "count": len(cached["emails"]), + "has_more": cached["has_more"], + }) + return + + collected_emails = [] + cached_channel = get_cached_channel(email_addr) + + # --- Graph API 尝试 --- + if account_type != "imap" and cached_channel != "imap": + graph_result = graph_service.get_emails_graph( + account["client_id"], account["refresh_token"], folder, skip, top, proxy_url, + ) + if graph_result.get("success"): + set_cached_channel(email_addr, "graph") + emails = graph_result.get("emails", []) + for e in emails: + fmt = { + "id": e.get("id"), + "subject": e.get("subject", "无主题"), + "from": e.get("from", {}).get("emailAddress", {}).get("address", "未知"), + "date": e.get("receivedDateTime", ""), + "is_read": e.get("isRead", False), + "has_attachments": e.get("hasAttachments", False), + "body_preview": e.get("bodyPreview", ""), + } + collected_emails.append(fmt) + yield _sse("email", fmt) + has_more = len(emails) >= top + set_cached_emails(email_addr, folder, collected_emails, "Graph API", has_more) + yield _sse("done", {"method": "Graph API", "count": len(emails), "has_more": has_more}) + return + elif graph_result.get("no_mail_permission"): + set_cached_channel(email_addr, "imap") + + # --- IMAP 流式 --- + yield _sse("status", {"message": "IMAP connecting..."}) + imap_method = "IMAP" + for item in imap_service.stream_emails_imap( + account["email"], account["client_id"], account["refresh_token"], + folder, skip, top, + ): + if item.get("type") == "email": + collected_emails.append(item["data"]) + yield _sse("email", item["data"]) + elif item.get("type") == "done": + imap_method = item.get("method", "IMAP") + set_cached_channel(email_addr, "imap") + set_cached_emails(email_addr, folder, collected_emails, imap_method, False) + yield _sse("done", {"method": imap_method, "count": item.get("count", 0), "has_more": False}) + elif item.get("type") == "error": + yield _sse("error", item.get("data", {})) + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + @login_required def api_delete_emails() -> Any: """批量删除邮件(永久删除)""" diff --git a/outlook_web/routes/emails.py b/outlook_web/routes/emails.py index d62eb38f..123c3b0d 100644 --- a/outlook_web/routes/emails.py +++ b/outlook_web/routes/emails.py @@ -13,6 +13,11 @@ def create_blueprint() -> Blueprint: view_func=emails_controller.api_get_emails, methods=["GET"], ) + bp.add_url_rule( + "/api/emails//stream", + view_func=emails_controller.api_stream_emails, + methods=["GET"], + ) bp.add_url_rule( "/api/emails//extract-verification", view_func=emails_controller.api_extract_verification, diff --git a/outlook_web/services/channel_cache.py b/outlook_web/services/channel_cache.py new file mode 100644 index 00000000..90a26250 --- /dev/null +++ b/outlook_web/services/channel_cache.py @@ -0,0 +1,43 @@ +""" +邮箱通道缓存:记住每个邮箱可用的读取方式(graph / imap),避免重复尝试必然失败的通道。 + +- graph: 有 Mail.Read 权限,直接走 Graph API +- imap: 无 Mail.Read 权限或 Graph API 持续失败,跳过 Graph 直走 IMAP +- TTL 1 小时后过期重新探测 +""" + +from __future__ import annotations + +import threading +import time +from typing import Optional + +_channel_cache: dict[str, dict] = {} +_cache_lock = threading.Lock() +_CACHE_TTL = 3600 # 1 hour + + +def get_cached_channel(email: str) -> Optional[str]: + """获取缓存的可用通道,过期返回 None。""" + with _cache_lock: + entry = _channel_cache.get(email) + if entry and entry["expires_at"] > time.time(): + return entry["method"] + if entry: + del _channel_cache[email] + return None + + +def set_cached_channel(email: str, method: str) -> None: + """缓存某邮箱的可用通道。""" + with _cache_lock: + _channel_cache[email] = { + "method": method, + "expires_at": time.time() + _CACHE_TTL, + } + + +def invalidate_channel(email: str) -> None: + """失效某邮箱的通道缓存。""" + with _cache_lock: + _channel_cache.pop(email, None) diff --git a/outlook_web/services/email_cache.py b/outlook_web/services/email_cache.py new file mode 100644 index 00000000..cc290919 --- /dev/null +++ b/outlook_web/services/email_cache.py @@ -0,0 +1,67 @@ +""" +邮件列表缓存:避免 2h 内重复拉取同一邮箱的同一文件夹。 + +- key: (email, folder) +- value: {emails, method, has_more, cached_at} +- TTL: 2 小时 +- 线程安全 +""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Dict, List, Optional + +_email_cache: dict[str, dict] = {} +_cache_lock = threading.Lock() +_CACHE_TTL = 7200 # 2 hours + + +def _make_key(email: str, folder: str) -> str: + return f"{email}|{folder}" + + +def get_cached_emails(email: str, folder: str) -> Optional[Dict[str, Any]]: + """命中缓存返回 {emails, method, has_more},未命中或过期返回 None。""" + key = _make_key(email, folder) + with _cache_lock: + entry = _email_cache.get(key) + if entry and entry["expires_at"] > time.time(): + return { + "emails": entry["emails"], + "method": entry["method"], + "has_more": entry["has_more"], + } + if entry: + del _email_cache[key] + return None + + +def set_cached_emails( + email: str, + folder: str, + emails: List[Dict], + method: str, + has_more: bool = False, +) -> None: + """写入缓存。""" + key = _make_key(email, folder) + with _cache_lock: + _email_cache[key] = { + "emails": emails, + "method": method, + "has_more": has_more, + "expires_at": time.time() + _CACHE_TTL, + } + + +def invalidate_email_cache(email: str, folder: Optional[str] = None) -> None: + """失效缓存。folder=None 时清除该邮箱所有文件夹。""" + with _cache_lock: + if folder: + _email_cache.pop(_make_key(email, folder), None) + else: + keys_to_remove = [k for k in _email_cache if k.startswith(f"{email}|")] + for k in keys_to_remove: + del _email_cache[k] diff --git a/outlook_web/services/graph.py b/outlook_web/services/graph.py index 526018c2..e7c9f87a 100644 --- a/outlook_web/services/graph.py +++ b/outlook_web/services/graph.py @@ -88,10 +88,17 @@ def get_access_token_graph_result(client_id: str, refresh_token: str, proxy_url: # 根据 Microsoft Learn 文档:refresh token 可能会在每次使用时“自我替换”,应保存新的 refresh_token(如有)。 new_refresh_token = payload.get("refresh_token") + + # 检查返回的 scope 是否包含 Mail.Read 权限 + scope = payload.get("scope", "") + has_mail_read = "Mail.Read" in scope or "Mail.ReadWrite" in scope + return { "success": True, "access_token": access_token, "refresh_token": new_refresh_token, + "scope": scope, + "has_mail_read": has_mail_read, } except Exception as exc: return { @@ -127,6 +134,20 @@ def get_emails_graph( if not token_result.get("success"): return {"success": False, "error": token_result.get("error")} + # 无 Mail.Read 权限时直接跳过,不浪费一次 Graph API 请求 + if not token_result.get("has_mail_read"): + return { + "success": False, + "no_mail_permission": True, + "error": build_error_payload( + "GRAPH_NO_MAIL_PERMISSION", + "Token 无 Mail.Read 权限,跳过 Graph API", + "GraphPermissionError", + 403, + token_result.get("scope", ""), + ), + } + access_token = token_result.get("access_token") try: diff --git a/outlook_web/services/imap.py b/outlook_web/services/imap.py index 38bc89b3..36ddb1c9 100644 --- a/outlook_web/services/imap.py +++ b/outlook_web/services/imap.py @@ -261,28 +261,280 @@ def get_emails_imap_with_server( paged_ids = message_ids[start_idx:end_idx][::-1] + # 批量 FETCH:一次请求取所有消息(避免 N 次串行网络往返) + id_list = b",".join(paged_ids) emails_data = [] + status, msg_data = connection.fetch(id_list, "(RFC822)") + + if status == "OK" and msg_data: + for item in msg_data: + if not isinstance(item, tuple) or len(item) < 2: + continue + try: + raw_email = item[1] + msg = email.message_from_bytes(raw_email) + seq = item[0].split(b" ", 1)[0] if isinstance(item[0], bytes) else b"0" + msg_id_str = seq.decode() if isinstance(seq, bytes) else str(seq) + + body_preview = get_email_body(msg) + emails_data.append( + { + "id": msg_id_str, + "subject": decode_header_value(msg.get("Subject", "无主题")), + "from": decode_header_value(msg.get("From", "未知发件人")), + "date": msg.get("Date", "未知时间"), + "body_preview": (body_preview[:200] + "..." if len(body_preview) > 200 else body_preview), + } + ) + except Exception: + continue + + return {"success": True, "emails": emails_data} + except Exception as exc: + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_FAILED", + "获取邮件失败,请检查账号配置", + type(exc).__name__, + 500, + str(exc), + ), + } + finally: + if connection: + try: + connection.logout() + except Exception: + pass + + +IMAP_SERVER_OLD = "outlook.office365.com" + + +def stream_emails_imap( + account: str, + client_id: str, + refresh_token: str, + folder: str = "inbox", + skip: int = 0, + top: int = 20, +): + """Generator:逐条 yield 邮件,供 SSE 流式推送。并发尝试新旧 IMAP 服务器。""" + from concurrent.futures import ThreadPoolExecutor, as_completed + + token_result = get_access_token_imap_result(client_id, refresh_token) + if not token_result.get("success"): + yield {"type": "error", "data": token_result.get("error", {})} + return + + access_token = token_result.get("access_token") + + # 并发尝试两个 IMAP 服务器,用第一个成功连接的 + def _try_connect(server): + try: + conn = imaplib.IMAP4_SSL(server, IMAP_PORT) + auth_string = f"user={account}\1auth=Bearer {access_token}\1\1".encode("utf-8") + conn.authenticate("XOAUTH2", lambda x: auth_string) + + folder_map = { + "inbox": ['"INBOX"', "INBOX"], + "junkemail": ['"Junk"', '"Junk Email"', "Junk"], + "deleteditems": ['"Deleted"', '"Deleted Items"', '"Trash"', "Deleted"], + "trash": ['"Deleted"', '"Deleted Items"', '"Trash"', "Deleted"], + } + for imap_folder in folder_map.get((folder or "").lower(), ['"INBOX"']): + try: + status, _ = conn.select(imap_folder, readonly=True) + if status == "OK": + return conn, server + except Exception: + continue + conn.logout() + return None, server + except Exception: + return None, server + + connection = None + winner_server = None + with ThreadPoolExecutor(max_workers=2) as ex: + futures = [ex.submit(_try_connect, s) for s in [IMAP_SERVER_NEW, IMAP_SERVER_OLD]] + for f in as_completed(futures): + conn, srv = f.result() + if conn and not connection: + connection = conn + winner_server = srv + elif conn: + try: + conn.logout() + except Exception: + pass + + if not connection: + yield {"type": "error", "data": {"code": "IMAP_CONNECT_FAILED", "message": "所有 IMAP 服务器连接失败"}} + return + + try: + status, messages = connection.search(None, "ALL") + if status != "OK" or not messages or not messages[0]: + yield {"type": "done", "method": "IMAP", "count": 0} + return + + message_ids = messages[0].split() + total = len(message_ids) + start_idx = max(0, total - skip - top) + end_idx = total - skip + if start_idx >= end_idx: + yield {"type": "done", "method": "IMAP", "count": 0} + return + + paged_ids = message_ids[start_idx:end_idx][::-1] + method = "IMAP (New)" if winner_server == IMAP_SERVER_NEW else "IMAP (Old)" + count = 0 + for msg_id in paged_ids: try: status, msg_data = connection.fetch(msg_id, "(RFC822)") if status == "OK" and msg_data and msg_data[0]: raw_email = msg_data[0][1] msg = email.message_from_bytes(raw_email) + body_preview = get_email_body(msg) + count += 1 + yield { + "type": "email", + "data": { + "id": (msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id)), + "subject": decode_header_value(msg.get("Subject", "无主题")), + "from": decode_header_value(msg.get("From", "未知发件人")), + "date": msg.get("Date", "未知时间"), + "body_preview": (body_preview[:200] + "..." if len(body_preview) > 200 else body_preview), + }, + } + except Exception: + continue + + yield {"type": "done", "method": method, "count": count} + finally: + try: + connection.logout() + except Exception: + pass + + +def _fetch_emails_with_token( + account: str, + access_token: str, + folder: str, + skip: int, + top: int, + server: str, +) -> Dict[str, Any]: + """使用已获取的 access_token 通过指定 IMAP 服务器读取邮件(内部方法)。""" + connection = None + try: + connection = imaplib.IMAP4_SSL(server, IMAP_PORT) + auth_string = f"user={account}\1auth=Bearer {access_token}\1\1".encode("utf-8") + connection.authenticate("XOAUTH2", lambda x: auth_string) + + folder_map = { + "inbox": ['"INBOX"', "INBOX"], + "junkemail": ['"Junk"', '"Junk Email"', "Junk", '"垃圾邮件"'], + "deleteditems": [ + '"Deleted"', + '"Deleted Items"', + '"Trash"', + "Deleted", + '"已删除邮件"', + ], + "trash": [ + '"Deleted"', + '"Deleted Items"', + '"Trash"', + "Deleted", + '"已删除邮件"', + ], + } + possible_folders = folder_map.get((folder or "").lower(), ['"INBOX"']) + + selected_folder = None + last_error = None + for imap_folder in possible_folders: + try: + status, response = connection.select(imap_folder, readonly=True) + if status == "OK": + selected_folder = imap_folder + break + last_error = f"select {imap_folder} status={status}" + except Exception as e: + last_error = f"select {imap_folder} error={str(e)}" + continue + + if not selected_folder: + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_FAILED", + "无法访问文件夹,请检查账号配置", + "IMAPSelectError", + 500, + {"last_error": last_error, "tried_folders": possible_folders}, + ), + } + + status, messages = connection.search(None, "ALL") + if status != "OK": + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_FAILED", + "获取邮件失败,请检查账号配置", + "IMAPSearchError", + 500, + f"search status={status}", + ), + } + if not messages or not messages[0]: + return {"success": True, "emails": [], "server": server} + + message_ids = messages[0].split() + total = len(message_ids) + start_idx = max(0, total - skip - top) + end_idx = total - skip + + if start_idx >= end_idx: + return {"success": True, "emails": [], "server": server} + + paged_ids = message_ids[start_idx:end_idx][::-1] + + # 批量 FETCH:一次请求取所有消息(避免 N 次串行网络往返) + id_list = b",".join(paged_ids) + emails_data = [] + status, msg_data = connection.fetch(id_list, "(RFC822)") + + if status == "OK" and msg_data: + for item in msg_data: + if not isinstance(item, tuple) or len(item) < 2: + continue + try: + raw_email = item[1] + msg = email.message_from_bytes(raw_email) + seq = item[0].split(b" ", 1)[0] if isinstance(item[0], bytes) else b"0" + msg_id_str = seq.decode() if isinstance(seq, bytes) else str(seq) body_preview = get_email_body(msg) emails_data.append( { - "id": (msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id)), + "id": msg_id_str, "subject": decode_header_value(msg.get("Subject", "无主题")), "from": decode_header_value(msg.get("From", "未知发件人")), "date": msg.get("Date", "未知时间"), "body_preview": (body_preview[:200] + "..." if len(body_preview) > 200 else body_preview), } ) - except Exception: - continue + except Exception: + continue - return {"success": True, "emails": emails_data} + return {"success": True, "emails": emails_data, "server": server} except Exception as exc: return { "success": False, @@ -302,6 +554,55 @@ def get_emails_imap_with_server( pass +def get_emails_imap_concurrent( + account: str, + client_id: str, + refresh_token: str, + folder: str = "inbox", + skip: int = 0, + top: int = 20, +) -> Dict[str, Any]: + """同时尝试新旧两个 IMAP 服务器,取第一个成功的结果。""" + from concurrent.futures import ThreadPoolExecutor, as_completed + + token_result = get_access_token_imap_result(client_id, refresh_token) + if not token_result.get("success"): + return {"success": False, "error": token_result.get("error")} + + access_token = token_result.get("access_token") + + with ThreadPoolExecutor(max_workers=2) as executor: + future_new = executor.submit( + _fetch_emails_with_token, account, access_token, folder, skip, top, IMAP_SERVER_NEW, + ) + future_old = executor.submit( + _fetch_emails_with_token, account, access_token, folder, skip, top, IMAP_SERVER_OLD, + ) + + all_errors = {} + for future in as_completed([future_new, future_old]): + result = future.result() + if result.get("success"): + server = result.pop("server", "IMAP") + method = "IMAP (New)" if server == IMAP_SERVER_NEW else "IMAP (Old)" + result["method"] = method + return result + else: + server = "new" if future is future_new else "old" + all_errors[f"imap_{server}"] = result.get("error") + + return { + "success": False, + "error": build_error_payload( + "EMAIL_FETCH_ALL_IMAP_FAILED", + "所有 IMAP 服务器均失败", + "IMAPError", + 502, + all_errors, + ), + } + + def get_email_detail_imap( account: str, client_id: str, diff --git a/static/js/features/emails.js b/static/js/features/emails.js index fb7ff437..8a55a23f 100644 --- a/static/js/features/emails.js +++ b/static/js/features/emails.js @@ -45,75 +45,105 @@ container.innerHTML = `
${translateAppTextLocal('获取中…')}
`; - try { - // 每次只查询20封邮件 - const response = await fetch( - `/api/emails/${encodeURIComponent(email)}?method=${currentMethod}&folder=${currentFolder}&skip=0&top=20` - ); - const data = await response.json(); + // SSE 流式加载:每收到一封邮件立即渲染,不等全部完成 + const forceParam = forceRefresh ? '&force=1' : ''; + const streamUrl = `/api/emails/${encodeURIComponent(email)}/stream?folder=${currentFolder}&skip=0&top=20${forceParam}`; + const evtSource = new EventSource(streamUrl); + let streamEmails = []; + let firstEmail = true; + const clickHandler = isTempEmailGroup ? 'getTempEmailDetail' : 'selectEmail'; - if (data.success) { - currentEmails = data.emails; - currentMethod = data.method === 'Graph API' ? 'graph' : 'imap'; - hasMoreEmails = data.has_more; - if (typeof syncAccountSummaryToAccountCache === 'function' && data.account_summary) { - syncAccountSummaryToAccountCache(email, data.account_summary); - } + evtSource.addEventListener('email', function(e) { + const emailData = JSON.parse(e.data); + streamEmails.push(emailData); - if (typeof syncAccountSummaryToAccountCache === 'function' && data.account_summary) { - syncAccountSummaryToAccountCache(email, data.account_summary); - } + // 首封邮件到达时清除 loading + if (firstEmail) { + container.innerHTML = ''; + firstEmail = false; + } - // 保存到缓存 - emailListCache[cacheKey] = { - emails: currentEmails, - has_more: hasMoreEmails, - skip: currentSkip, - method: currentMethod - }; + // 逐条 DOM append + const index = streamEmails.length - 1; + const initial = (emailData.from || '?')[0].toUpperCase(); + const div = document.createElement('div'); + div.className = `email-item${emailData.is_read === false ? ' unread' : ''}`; + div.setAttribute('onclick', `${clickHandler}('${emailData.id}', ${index})`); + div.innerHTML = ` + + + + + `; + container.appendChild(div); - // 显示使用的方法和邮件数量 - const methodTag = document.getElementById('methodTag'); - methodTag.textContent = data.method; - methodTag.style.display = 'inline'; + // 实时更新计数 + document.getElementById('emailCount').textContent = `(${streamEmails.length})`; + }); - document.getElementById('emailCount').textContent = `(${data.emails.length})`; + evtSource.addEventListener('done', function(e) { + const info = JSON.parse(e.data); + evtSource.close(); - renderEmailList(data.emails); - } else { - // 显示详细的多方法失败弹框 - if (data.details) { - showEmailFetchErrorModal(data.details); - } else { - handleApiError(data, '获取邮件失败'); - } + currentEmails = streamEmails; + currentMethod = info.method.includes('Graph API') ? 'graph' : 'imap'; + hasMoreEmails = info.has_more || false; + + // 如果没收到任何邮件 + if (streamEmails.length === 0) { container.innerHTML = `
- ⚠️

${translateAppTextLocal('获取邮件失败,')}${translateAppTextLocal('点击查看详情')}

+ 📭 +

${translateAppTextLocal('收件箱为空')}

`; - lastFetchErrorDetails = data.details || {}; - // 绑定事件监听器 - const errorLink = document.getElementById('showEmailErrorLink'); - if (errorLink) { - errorLink.addEventListener('click', () => showEmailFetchErrorModal(lastFetchErrorDetails)); - } } - } catch (error) { - console.error('加载邮件列表失败:', error); - container.innerHTML = ` -
- ⚠️

${translateAppTextLocal('网络错误,请重试')}

-
- `; - } finally { + + // 更新 method tag + const methodTag = document.getElementById('methodTag'); + methodTag.textContent = info.method; + methodTag.style.display = 'inline'; + + // 保存到缓存 + emailListCache[cacheKey] = { + emails: currentEmails, + has_more: hasMoreEmails, + skip: currentSkip, + method: currentMethod + }; + // 启用按钮 if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = translateAppTextLocal('获取邮件'); } folderTabs.forEach(tab => tab.disabled = false); - } + }); + + evtSource.addEventListener('error', function(e) { + // SSE 自身 error event(非服务端 error event) + if (evtSource.readyState === EventSource.CLOSED) return; + evtSource.close(); + + if (streamEmails.length === 0) { + container.innerHTML = ` +
+ ⚠️

${translateAppTextLocal('获取邮件失败')}

+
+ `; + } + if (refreshBtn) { + refreshBtn.disabled = false; + refreshBtn.textContent = translateAppTextLocal('获取邮件'); + } + folderTabs.forEach(tab => tab.disabled = false); + }); } // 渲染邮件列表 From 30dd2f867db43645058c44e8596d78251704f947 Mon Sep 17 00:00:00 2001 From: hongdongjian Date: Tue, 19 May 2026 20:52:18 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(imap):=20=E4=BF=AE=E5=A4=8D=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=A0=81=E6=8F=90=E5=8F=96=E8=AF=A6=E6=83=85=E9=94=99?= =?UTF-8?q?=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- outlook_web/services/imap.py | 9 ++- .../services/verification_channel_routing.py | 11 ++- tests/test_imap_connection_reuse.py | 26 +++++++ tests/test_verification_extract_log.py | 71 +++++++++++++++++++ 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/outlook_web/services/imap.py b/outlook_web/services/imap.py index 2df6d178..43d6e5e1 100644 --- a/outlook_web/services/imap.py +++ b/outlook_web/services/imap.py @@ -472,7 +472,14 @@ def fetch_and_detail_imap_with_server( if status != "OK": return {"success": True, "emails": [], "detail": None} - for i, (msg_id_str, raw_email) in enumerate(_parse_batch_fetch_response(all_data or [])): + raw_by_id = {msg_id_str: raw_email for msg_id_str, raw_email in _parse_batch_fetch_response(all_data or [])} + + for i, msg_id in enumerate(paged_ids): + msg_id_str = msg_id.decode("ascii", errors="ignore").strip() + raw_email = raw_by_id.get(msg_id_str) + if raw_email is None: + continue + msg = email.message_from_bytes(raw_email) body_preview = get_email_body(msg) email_item = { diff --git a/outlook_web/services/verification_channel_routing.py b/outlook_web/services/verification_channel_routing.py index b6243408..767b0657 100644 --- a/outlook_web/services/verification_channel_routing.py +++ b/outlook_web/services/verification_channel_routing.py @@ -408,13 +408,22 @@ def extract_verification_for_outlook( reverse=True, )[0] + latest_id = str(latest.get("id") or "") if channel.startswith("imap_"): detail = channel_result.get("detail") + detail_id = str((detail or {}).get("id") or "") + if latest_id and detail_id and detail_id != latest_id: + detail = fetch_email_detail_for_channel( + account=account, + channel=channel, + message_id=latest_id, + proxy_url=proxy_url, + ) else: detail = fetch_email_detail_for_channel( account=account, channel=channel, - message_id=latest.get("id", ""), + message_id=latest_id, proxy_url=proxy_url, ) diff --git a/tests/test_imap_connection_reuse.py b/tests/test_imap_connection_reuse.py index c4845e8d..adc726a7 100644 --- a/tests/test_imap_connection_reuse.py +++ b/tests/test_imap_connection_reuse.py @@ -194,6 +194,32 @@ def test_imap_connection_created_only_once(self, mock_token, mock_imap_cls): self.assertEqual(mock_imap_cls.call_count, 1) self.assertEqual(mock_conn.authenticate.call_count, 1) + @patch("outlook_web.services.imap.imaplib.IMAP4_SSL") + @patch("outlook_web.services.imap.get_access_token_imap_result") + def test_batch_fetch_response_is_reordered_to_requested_latest_first(self, mock_token, mock_imap_cls): + from outlook_web.services.imap import fetch_and_detail_imap_with_server + + mock_token.return_value = self._mock_token_result(True) + raw_old = _build_rfc822_bytes("Old code", "Your code is 990595") + raw_middle = _build_rfc822_bytes("Middle code", "Your code is 118658") + raw_new = _build_rfc822_bytes("New code", "Your code is 701280") + mock_conn = self._setup_imap_mock(mock_imap_cls, search_ids=[b"1", b"2", b"3", b"4", b"5"]) + mock_conn.fetch.return_value = ( + "OK", + [ + ((b"3 (RFC822)", raw_old), b")"), + ((b"4 (RFC822)", raw_middle), b")"), + ((b"5 (RFC822)", raw_new), b")"), + ], + ) + + result = fetch_and_detail_imap_with_server("user@test.com", "cid", "rt", folder="inbox", top=3) + + self.assertTrue(result.get("success")) + self.assertEqual([item["id"] for item in result.get("emails", [])], ["5", "4", "3"]) + self.assertEqual(result.get("detail", {}).get("id"), "5") + self.assertIn("701280", result.get("detail", {}).get("body", "")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_verification_extract_log.py b/tests/test_verification_extract_log.py index 1641f3a0..97d0b237 100644 --- a/tests/test_verification_extract_log.py +++ b/tests/test_verification_extract_log.py @@ -327,3 +327,74 @@ def test_log_channel_is_ai_fallback_when_ai_is_used(self): self.assertIn("_log_channel", result) self.assertEqual(result["_log_channel"], "ai_fallback") + + def test_imap_detail_mismatch_refetches_latest_message_detail(self): + """IMAP 连接复用返回的 detail 与最新邮件不一致时,应按 latest.id 重新取详情。""" + with self.app.app_context(): + from outlook_web.services import verification_channel_routing as vcr + + fake_account = { + "id": 3, + "email": "imap@outlook.com", + "account_type": "outlook", + "provider": "outlook", + "group_id": None, + "preferred_verification_channel": "imap_new", + "client_id": "cid", + "refresh_token": "rt", + } + + fake_channel_result = { + "success": True, + "emails": [ + { + "id": "3", + "subject": "Old code", + "from": "OpenAI", + "date": "Tue, 19 May 2026 10:01:15 +0000", + }, + { + "id": "5", + "subject": "New code", + "from": "OpenAI", + "date": "Tue, 19 May 2026 10:38:27 +0000", + }, + ], + "detail": { + "id": "3", + "subject": "Old code", + "from": "OpenAI", + "date": "Tue, 19 May 2026 10:01:15 +0000", + "body": "Your code is 990595", + }, + } + latest_detail = { + "id": "5", + "subject": "New code", + "from": "OpenAI", + "date": "Tue, 19 May 2026 10:38:27 +0000", + "body": "Your code is 701280", + } + + with ( + patch.object(vcr, "build_verification_channel_plan", return_value=["imap_new"]), + patch.object(vcr, "fetch_emails_and_detail_for_channel", return_value=fake_channel_result), + patch.object(vcr, "fetch_email_detail_for_channel", return_value=latest_detail) as mock_fetch_detail, + patch( + "outlook_web.services.graph.get_access_token_graph_result", + return_value={"success": False}, + ), + patch("outlook_web.repositories.accounts.update_preferred_verification_channel"), + ): + result = vcr.extract_verification_for_outlook( + account=fake_account, + resolved_policy={"code_regex": r"(? Date: Sun, 31 May 2026 11:57:29 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20watchtower=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E8=BF=87=E6=97=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue [#65](https://github.com/ZeroPointSix/outlookEmailPlus/issues/65) 由于原版watchtower已archived,其硬编码的 API v1.25 在 Docker v29 已不被兼容,导致watchtower镜像在启动时会报错: ``` level=error msg="Error response from daemon: client version 1.25 is too old. Minimum supported API version is 1.40, please upgrade your client to a newer version" ``` 从而无法正常启动 解决方案:切换到nicholas-fedor维护的fork --- README.en.md | 2 +- README.md | 2 +- docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.en.md b/README.en.md index b0f3d940..109ef098 100644 --- a/README.en.md +++ b/README.en.md @@ -174,7 +174,7 @@ services: - outlook-net watchtower: - image: containrrr/watchtower:1.7.1 + image: nickfedor/watchtower:latest container_name: watchtower restart: unless-stopped volumes: diff --git a/README.md b/README.md index 62475b20..fb79fe1d 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ services: - outlook-net watchtower: - image: containrrr/watchtower:1.7.1 + image: nickfedor/watchtower:latest container_name: watchtower restart: unless-stopped volumes: diff --git a/docker-compose.yml b/docker-compose.yml index ed1b655a..9c8bec9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,7 +75,7 @@ services: - outlook-net watchtower: - image: containrrr/watchtower:1.7.1 + image: nickfedor/watchtower:latest container_name: watchtower restart: unless-stopped volumes: From 50f7bbbb0890e56df3939798631959a4ce257cfc Mon Sep 17 00:00:00 2001 From: Riley Morgan Date: Sun, 5 Jul 2026 13:53:01 +0000 Subject: [PATCH 5/8] =?UTF-8?q?fix(verification):=20=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81=E6=8F=90=E5=8F=96=E4=BF=9D=E7=95=99=E5=8E=9F=E6=96=87?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规则分支与 AI 回退不再强制 .upper(),verification_code 与 formatted 保持邮件原文大小写;补充回归测试。 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + .../services/verification_extractor.py | 12 +++-- tests/test_ai_fallback_trigger_condition.py | 25 +++++++++++ tests/test_verification_extractor.py | 45 +++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a69da157..4a67b2a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OutlookMail Plus are documented in this file. ### 修复 / Bug Fixes +- **验证码提取大小写被篡改**:验证码提取收口处(规则分支 `_smart_extract_code_by_keywords` / `_fallback_extract_code` 及历史函数 `smart_extract_verification_code` / `fallback_extract_verification_code`,以及 AI 回退 `enhance_verification_with_ai_fallback`)不再强制 `.upper()`,`verification_code` 与 `formatted` 保持邮件原文大小写(如 `ab12cd` 不再返回 `AB12CD`)。补充回归测试。 - **Issue #65 Watchtower 容器镜像过时**:`docker-compose.yml` 中固定 Watchtower 版本为 `containrrr/watchtower:1.7.1`,避免本地缓存的旧版镜像(内嵌 Docker 客户端 API 1.25)连接新版本 Docker Engine(要求 API 1.44+)时失败。README 新增故障排查指引。 ## [v2.7.0] - 2026-05-29 diff --git a/outlook_web/services/verification_extractor.py b/outlook_web/services/verification_extractor.py index 340df2c8..3d0d4a7b 100644 --- a/outlook_web/services/verification_extractor.py +++ b/outlook_web/services/verification_extractor.py @@ -154,7 +154,7 @@ def smart_extract_verification_code(email_content: str) -> Optional[str]: # 过滤掉纯字母的匹配(验证码通常包含数字) for match in matches: if any(c.isdigit() for c in match): - return match.upper() + return match return None @@ -183,8 +183,6 @@ def fallback_extract_verification_code(email_content: str) -> Optional[str]: # 过滤规则 filtered = [] for match in matches: - match_upper = match.upper() - # 必须包含至少一个数字 if not any(c.isdigit() for c in match): continue @@ -209,7 +207,7 @@ def fallback_extract_verification_code(email_content: str) -> Optional[str]: if 2020 <= num <= 2030: continue - filtered.append(match_upper) + filtered.append(match) return filtered[0] if filtered else None @@ -460,7 +458,7 @@ def _smart_extract_code_by_keywords(email_content: str, code_re: re.Pattern) -> for m in code_re.finditer(context): value = m.group(0) if value and any(c.isdigit() for c in value): - return value.upper() + return value return None @@ -492,7 +490,7 @@ def _fallback_extract_code(email_content: str, code_re: re.Pattern) -> Optional[ if 2020 <= num <= 2030: continue - candidates.append(value.upper()) + candidates.append(value) return candidates[0] if candidates else None @@ -1055,7 +1053,7 @@ def _apply_output_policy(payload: Dict[str, Any]) -> Dict[str, Any]: updated = False if ai_code: - result["verification_code"] = ai_code.upper() + result["verification_code"] = ai_code result["code_confidence"] = "high" if ai_confidence == "high" else "low" updated = True if ai_link: diff --git a/tests/test_ai_fallback_trigger_condition.py b/tests/test_ai_fallback_trigger_condition.py index e9966619..dc42c4d7 100644 --- a/tests/test_ai_fallback_trigger_condition.py +++ b/tests/test_ai_fallback_trigger_condition.py @@ -193,6 +193,31 @@ def test_code_high_link_low_enforces_mutual_exclusion(self, mock_config, mock_ai self.assertEqual(result["link_confidence"], "low") +class AiFallbackCasePreservationTests(unittest.TestCase): + """验证码大小写:AI fallback 返回的验证码必须保持原大小写""" + + @patch("outlook_web.services.verification_extractor._call_verification_ai") + @patch("outlook_web.services.verification_extractor.get_verification_ai_runtime_config") + def test_ai_code_case_preserved(self, mock_config, mock_ai_call): + """AI 返回小写 code → 结果保持小写,不转大写""" + mock_config.return_value = _AI_CONFIG + mock_ai_call.return_value = { + "schema_version": "verification_ai_v1", + "verification_code": "cbdfdb", + "verification_link": "", + "confidence": "high", + "reason": "test", + } + extracted = _make_extracted("low", "low") + + result = extractor.enhance_verification_with_ai_fallback(email=_EMAIL_OBJ, extracted=extracted) + + mock_ai_call.assert_called_once() + self.assertTrue(result.get("ai_used")) + self.assertEqual(result["verification_code"], "cbdfdb") + self.assertEqual(result["formatted"], "cbdfdb") + + class AiFallbackEdgeCaseTests(unittest.TestCase): """边界情况:缺失 confidence 字段时默认为 low""" diff --git a/tests/test_verification_extractor.py b/tests/test_verification_extractor.py index 4f435c4f..3909d980 100644 --- a/tests/test_verification_extractor.py +++ b/tests/test_verification_extractor.py @@ -413,6 +413,51 @@ def test_extract_email_text_body_content_type(self): text = extract_email_text(email) self.assertIn("555666", text) + # ==================== 大小写保持测试(回归) ==================== + + def test_smart_extract_preserves_lowercase(self): + """ + 测试用例:智能识别 - 保持小写验证码原样 + + 测试目的:验证智能识别不再私自转换大小写(含数字的小写字母验证码) + """ + content = "Your verification code is ab12cd. Please verify." + result = smart_extract_verification_code(content) + self.assertEqual(result, "ab12cd") + + def test_smart_extract_preserves_mixed_case(self): + """ + 测试用例:智能识别 - 保持大小写混合验证码原样 + """ + content = "您的验证码是 Ab12Cd,请尽快使用。" + result = smart_extract_verification_code(content) + self.assertEqual(result, "Ab12Cd") + + def test_fallback_extract_preserves_lowercase(self): + """ + 测试用例:保底提取 - 保持小写验证码原样 + """ + content = "Please use ab12cd to complete your registration." + result = fallback_extract_verification_code(content) + self.assertEqual(result, "ab12cd") + + def test_fallback_extract_preserves_mixed_case(self): + """ + 测试用例:保底提取 - 保持大小写混合验证码原样 + """ + content = "Please use Ab12Cd to complete your registration." + result = fallback_extract_verification_code(content) + self.assertEqual(result, "Ab12Cd") + + def test_extract_verification_info_preserves_case(self): + """ + 测试用例:完整流程 - verification_code 与 formatted 均保持原大小写 + """ + email = {"body": "Your verification code is ab12cd."} + result = extract_verification_info_from_text(email["body"]) + self.assertEqual(result["verification_code"], "ab12cd") + self.assertEqual(result["formatted"], "ab12cd") + if __name__ == "__main__": unittest.main() From c36787288a7d4171b74c0a16615a12078a1facb5 Mon Sep 17 00:00:00 2001 From: CodeXWeb Date: Sun, 5 Jul 2026 15:37:32 +0000 Subject: [PATCH 6/8] 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 7/8] 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", From 91895b259f9bb232157cad3b9a5e60b7feda8fc4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Jul 2026 16:36:22 +0000 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=E5=90=8E=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=AF=AD=E6=B3=95=E9=94=99=E8=AF=AF=E3=80=81=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20project=5Fkey=20=E9=80=82=E9=85=8D=E4=B8=8E=20FK=20?= =?UTF-8?q?=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 emails.py 中 api_stream_emails 的装饰器语法错误 - 为邮箱池 API 测试补充必填 project_key 与 consumer_key 校验 - 修复 external_pool 测试 setUp 中外键约束导致的清理失败 - 对齐 v22 项目复用语义下的测试期望值 Co-authored-by: ZeroPointSix --- outlook_web/controllers/emails.py | 6 ++--- tests/test_external_pool.py | 30 +++++++++++++++++++----- tests/test_pool_flow_suite.py | 39 ++++++++++++++----------------- 3 files changed, 44 insertions(+), 31 deletions(-) diff --git a/outlook_web/controllers/emails.py b/outlook_web/controllers/emails.py index 58c20f93..15f0310c 100644 --- a/outlook_web/controllers/emails.py +++ b/outlook_web/controllers/emails.py @@ -1554,6 +1554,8 @@ def api_external_get_probe_status(probe_id: str) -> Any: ) return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 + +@login_required def api_stream_emails(email_addr: str) -> Any: """SSE 流式获取邮件:IMAP 每拉到一封就推一条 event,前端边收边渲染。""" account = accounts_repo.get_account_by_email(email_addr) @@ -1648,7 +1650,3 @@ def generate(): mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) - - -@login_required - diff --git a/tests/test_external_pool.py b/tests/test_external_pool.py index 303bc118..0a0f249f 100644 --- a/tests/test_external_pool.py +++ b/tests/test_external_pool.py @@ -23,6 +23,9 @@ def setUp(self): db.execute( "DELETE FROM account_claim_logs WHERE account_id IN (SELECT id FROM accounts WHERE email LIKE '%@extpool.test')" ) + db.execute( + "DELETE FROM account_project_usage WHERE account_id IN (SELECT id FROM accounts WHERE email LIKE '%@extpool.test')" + ) db.execute("DELETE FROM accounts WHERE email LIKE '%@extpool.test'") db.commit() settings_repo.set_setting("external_api_key", "") @@ -164,7 +167,7 @@ def test_external_pool_claim_release_requires_api_key(self): claim_resp = client.post( "/api/external/pool/claim-random", headers=self._auth_headers(), - json={"caller_id": "ext-worker-01", "task_id": "release-no-key", "provider": "outlook"}, + json={"caller_id": "ext-worker-01", "project_key": "test_project", "task_id": "release-no-key", "provider": "outlook"}, ) self.assertEqual(claim_resp.status_code, 200) claim_data = claim_resp.get_json()["data"] @@ -176,6 +179,7 @@ def test_external_pool_claim_release_requires_api_key(self): "claim_token": claim_data["claim_token"], "caller_id": "ext-worker-01", "task_id": "release-no-key", + "project_key": "test_project", }, ) @@ -194,7 +198,7 @@ def test_external_pool_claim_complete_requires_api_key(self): claim_resp = client.post( "/api/external/pool/claim-random", headers=self._auth_headers(), - json={"caller_id": "ext-worker-01", "task_id": "complete-no-key", "provider": "outlook"}, + json={"caller_id": "ext-worker-01", "project_key": "test_project", "task_id": "complete-no-key", "provider": "outlook"}, ) self.assertEqual(claim_resp.status_code, 200) claim_data = claim_resp.get_json()["data"] @@ -206,6 +210,7 @@ def test_external_pool_claim_complete_requires_api_key(self): "claim_token": claim_data["claim_token"], "caller_id": "ext-worker-01", "task_id": "complete-no-key", + "project_key": "test_project", "result": "success", }, ) @@ -228,6 +233,7 @@ def test_external_pool_claim_random_success(self): json={ "caller_id": "ext-worker-01", "task_id": "task-ext-001", + "project_key": "test_project", "provider": "outlook", }, ) @@ -256,6 +262,7 @@ def test_external_pool_post_does_not_require_csrf(self): json={ "caller_id": "csrf-free-worker", "task_id": "csrf-free-task", + "project_key": "test_project", "provider": "outlook", }, ) @@ -302,6 +309,7 @@ def test_external_pool_claim_release_caller_mismatch(self): json={ "caller_id": "ext-worker-01", "task_id": "task-ext-002", + "project_key": "test_project", "provider": "outlook", }, ) @@ -316,6 +324,7 @@ def test_external_pool_claim_release_caller_mismatch(self): "claim_token": claim_data["claim_token"], "caller_id": "ext-worker-02", "task_id": "task-ext-002", + "project_key": "test_project", }, ) @@ -339,6 +348,7 @@ def test_external_pool_claim_complete_success(self): json={ "caller_id": "ext-worker-01", "task_id": "task-ext-complete", + "project_key": "test_project", "provider": "outlook", }, ) @@ -353,6 +363,7 @@ def test_external_pool_claim_complete_success(self): "claim_token": claim_data["claim_token"], "caller_id": "ext-worker-01", "task_id": "task-ext-complete", + "project_key": "test_project", "result": "success", "detail": "done", }, @@ -362,7 +373,7 @@ def test_external_pool_claim_complete_success(self): data = complete_resp.get_json() self.assertTrue(data.get("success")) self.assertEqual(data.get("code"), "OK") - self.assertEqual(data.get("data", {}).get("pool_status"), "used") + self.assertEqual(data.get("data", {}).get("pool_status"), "available") def test_external_pool_stats_success(self): client = self.app.test_client() @@ -429,7 +440,7 @@ def test_external_pool_claim_random_disabled_in_public_mode(self): resp = client.post( "/api/external/pool/claim-random", headers=self._auth_headers(), - json={"caller_id": "ext-worker-01", "task_id": "task-ext-disabled"}, + json={"caller_id": "ext-worker-01", "project_key": "test_project", "task_id": "task-ext-disabled"}, ) self.assertEqual(resp.status_code, 403) @@ -451,6 +462,7 @@ def test_external_pool_claim_release_disabled_in_public_mode(self): json={ "caller_id": "ext-worker-01", "task_id": "task-ext-rel", + "project_key": "test_project", "provider": "outlook", }, ) @@ -469,6 +481,7 @@ def test_external_pool_claim_release_disabled_in_public_mode(self): "claim_token": claim_data["claim_token"], "caller_id": "ext-worker-01", "task_id": "task-ext-rel", + "project_key": "test_project", }, ) @@ -491,6 +504,7 @@ def test_external_pool_claim_complete_disabled_in_public_mode(self): json={ "caller_id": "ext-worker-01", "task_id": "task-ext-comp", + "project_key": "test_project", "provider": "outlook", }, ) @@ -509,6 +523,7 @@ def test_external_pool_claim_complete_disabled_in_public_mode(self): "claim_token": claim_data["claim_token"], "caller_id": "ext-worker-01", "task_id": "task-ext-comp", + "project_key": "test_project", "result": "success", }, ) @@ -551,6 +566,7 @@ def test_external_pool_claim_random_requires_pool_access_for_multi_key(self): json={ "caller_id": "ext-worker-01", "task_id": "task-ext-no-access", + "project_key": "test_project", "provider": "outlook", }, ) @@ -572,7 +588,7 @@ def test_external_pool_claim_release_requires_pool_access_for_multi_key(self): claim_resp = client.post( "/api/external/pool/claim-random", headers=self._auth_headers("multi-pool-release-allow"), - json={"caller_id": "ext-worker-01", "task_id": "release-deny", "provider": "outlook"}, + json={"caller_id": "ext-worker-01", "project_key": "test_project", "task_id": "release-deny", "provider": "outlook"}, ) self.assertEqual(claim_resp.status_code, 200) claim_data = claim_resp.get_json()["data"] @@ -585,6 +601,7 @@ def test_external_pool_claim_release_requires_pool_access_for_multi_key(self): "claim_token": claim_data["claim_token"], "caller_id": "ext-worker-01", "task_id": "release-deny", + "project_key": "test_project", }, ) @@ -604,7 +621,7 @@ def test_external_pool_claim_complete_requires_pool_access_for_multi_key(self): claim_resp = client.post( "/api/external/pool/claim-random", headers=self._auth_headers("multi-pool-complete-allow"), - json={"caller_id": "ext-worker-01", "task_id": "complete-deny", "provider": "outlook"}, + json={"caller_id": "ext-worker-01", "project_key": "test_project", "task_id": "complete-deny", "provider": "outlook"}, ) self.assertEqual(claim_resp.status_code, 200) claim_data = claim_resp.get_json()["data"] @@ -617,6 +634,7 @@ def test_external_pool_claim_complete_requires_pool_access_for_multi_key(self): "claim_token": claim_data["claim_token"], "caller_id": "ext-worker-01", "task_id": "complete-deny", + "project_key": "test_project", "result": "success", }, ) diff --git a/tests/test_pool_flow_suite.py b/tests/test_pool_flow_suite.py index 419c3cac..a787bef1 100644 --- a/tests/test_pool_flow_suite.py +++ b/tests/test_pool_flow_suite.py @@ -96,7 +96,7 @@ def _make_pool_account( finally: conn.close() - def test_claim_complete_success_without_project_key_still_changes_status_to_used(self): + def test_claim_complete_requires_project_key(self): self._make_pool_account() claim_resp = self._claim(task_id="success_flow", project_key="register") @@ -116,23 +116,10 @@ def test_claim_complete_success_without_project_key_still_changes_status_to_used "detail": "manual suite success", }, ) - self.assertEqual(complete_resp.status_code, 200) + self.assertEqual(complete_resp.status_code, 400) complete_data = json.loads(complete_resp.data) - self.assertTrue(complete_data["success"]) - # 未传 project_key 时,旧行为仍为 success → used - 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() + self.assertFalse(complete_data["success"]) + self.assertEqual(complete_data["code"], "PROJECT_KEY_EMPTY") def test_claim_complete_success_with_project_key_on_long_lived_account_returns_available(self): account = self._make_pool_account(email_domain=f"reuse_{uuid.uuid4().hex[:8]}.test") @@ -159,6 +146,7 @@ def test_claim_complete_success_with_project_key_on_long_lived_account_returns_a "claim_token": claim_data["data"]["claim_token"], "caller_id": "reuse_bot", "task_id": "reuse_success_1", + "project_key": "project_alpha", "result": "success", }, ) @@ -199,6 +187,7 @@ def test_claim_complete_failure_changes_status_to_cooldown(self): "claim_token": claim_data["data"]["claim_token"], "caller_id": "suite_bot", "task_id": "cooldown_flow", + "project_key": "register", "result": "verification_timeout", "detail": "manual suite timeout", }, @@ -237,7 +226,7 @@ def test_multiple_consecutive_claims_do_not_repeat_accounts(self): headers=self._auth_headers(), json={ "caller_id": "suite_bot", - "task_id": f"batch_claim_{idx}", + "project_key": "test_project", "task_id": f"batch_claim_{idx}", "email_domain": email_domain, # 使用 email_domain 过滤 }, ) @@ -264,7 +253,7 @@ def test_multiple_consecutive_claims_do_not_repeat_accounts(self): "account_id": account_id, "claim_token": claim_token, "caller_id": "suite_bot", - "task_id": task_id, + "project_key": "test_project", "task_id": task_id, "reason": "suite cleanup", }, ) @@ -293,7 +282,7 @@ def test_claim_response_includes_email_domain_and_claimed_at(self): resp = self.client.post( "/api/external/pool/claim-random", headers=self._auth_headers(), - json={"caller_id": "domain_bot", "task_id": "domain_check"}, + json={"caller_id": "domain_bot", "project_key": "test_project", "task_id": "domain_check"}, ) self.assertEqual(resp.status_code, 200) data = json.loads(resp.data) @@ -306,7 +295,7 @@ 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"}, + json={"caller_id": "domain_bot", "project_key": "test_project", "task_id": "domain_required"}, ) self.assertEqual(resp.status_code, 400) data = json.loads(resp.data) @@ -325,6 +314,7 @@ def test_claim_domain_filters_to_requested_domain(self): json={ "caller_id": "domain_bot", "task_id": "domain_claim", + "project_key": "test_project", "email_domain": requested_domain.upper(), }, ) @@ -367,6 +357,7 @@ def test_claim_with_project_key_prevents_same_project_reuse_without_manual_statu "claim_token": data1["data"]["claim_token"], "caller_id": "proj_bot", "task_id": "proj_task_1", + "project_key": "project_alpha", "result": "success", }, ) @@ -423,6 +414,7 @@ def test_claim_with_different_project_key_allows_immediate_reuse_after_success(s "claim_token": data1["data"]["claim_token"], "caller_id": "proj_bot", "task_id": "pb_task_1", + "project_key": "project_beta", "result": "success", }, ) @@ -456,6 +448,7 @@ def test_claim_with_different_project_key_allows_immediate_reuse_after_success(s "claim_token": data2["data"]["claim_token"], "caller_id": "proj_bot", "task_id": "pg_task_1", + "project_key": "test_project", }, ) @@ -485,6 +478,7 @@ def test_claim_complete_success_updates_stats_to_available_not_used(self): "claim_token": claim_data["data"]["claim_token"], "caller_id": "stats_bot", "task_id": "stats_task_1", + "project_key": "project_stats", "result": "success", }, ) @@ -531,6 +525,7 @@ def test_same_project_verification_timeout_does_not_block_retry_after_recovery(s "claim_token": claim_data["data"]["claim_token"], "caller_id": "timeout_bot", "task_id": "timeout_task_1", + "project_key": "project_timeout", "result": "verification_timeout", }, ) @@ -588,6 +583,7 @@ def test_same_project_manual_release_does_not_block_retry(self): "claim_token": claim_data["data"]["claim_token"], "caller_id": "release_bot", "task_id": "release_task_1", + "project_key": "project_release", }, ) self.assertEqual(release_resp.status_code, 200) @@ -641,6 +637,7 @@ def test_cloudflare_temp_mail_success_with_project_key_still_returns_old_status_ "claim_token": claim_data["data"]["claim_token"], "caller_id": "cf_bot", "task_id": "cf_task_1", + "project_key": "project_cf", "result": "success", }, )