feat(email): add configurable resend limits for non-OpenAI sender emails

This commit is contained in:
cnlimiter
2026-03-28 02:51:12 +08:00
parent b751d656ea
commit 51922ef2a6
30 changed files with 815 additions and 1263 deletions

155
tests/check_otp_timing.py Normal file
View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
离线验证 TempmailService 的 OTP 时间锚点过滤行为。
场景 1:
- 30 秒内先后收到两封邮件
- 在两封邮件之间设置新的 otp_sent_at
- 期望过滤第一封,命中第二封
场景 2:
- 第二封邮件已经入箱后才刷新 otp_sent_at
- 期望复现严格时间过滤导致第二封也被排除的窗口
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import Any, Dict, List, Optional
import src.services.tempmail as tempmail_module
from src.services.tempmail import TempmailService
@dataclass(frozen=True)
class Scenario:
name: str
anchor_offset_seconds: int
expected_code: Optional[str]
expected_message: str
class FakeResponse:
def __init__(self, payload: Dict[str, Any], status_code: int = 200):
self._payload = payload
self.status_code = status_code
def json(self) -> Dict[str, Any]:
return self._payload
class FakeHTTPClient:
def __init__(self, payload: Dict[str, Any]):
self.payload = payload
self.calls: List[Dict[str, Any]] = []
def get(self, url: str, **kwargs: Any) -> FakeResponse:
self.calls.append({"url": url, "kwargs": kwargs})
return FakeResponse(self.payload)
class FakeClock:
def __init__(self, start: float):
self.current = float(start)
def time(self) -> float:
return self.current
def sleep(self, seconds: float) -> None:
self.current += float(seconds)
def build_inbox_payload(base_timestamp: int) -> Dict[str, Any]:
return {
"emails": [
{
"id": "mail-1",
"received_at": base_timestamp + 10,
"from": "noreply@openai.com",
"subject": "First OTP",
"body": "111111",
},
{
"id": "mail-2",
"received_at": base_timestamp + 20,
"from": "noreply@openai.com",
"subject": "Second OTP",
"body": "222222",
},
]
}
def run_scenario(scenario: Scenario) -> Dict[str, Any]:
base_timestamp = 1_700_000_000
service = TempmailService({"base_url": "https://api.tempmail.test"})
service._email_cache["tester@example.com"] = {"token": "token-1"}
service.http_client = FakeHTTPClient(build_inbox_payload(base_timestamp))
fake_clock = FakeClock(start=base_timestamp + scenario.anchor_offset_seconds)
anchor_timestamp = fake_clock.time()
original_time = tempmail_module.time.time
original_sleep = tempmail_module.time.sleep
try:
tempmail_module.time.time = fake_clock.time
tempmail_module.time.sleep = fake_clock.sleep
code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=anchor_timestamp,
)
finally:
tempmail_module.time.time = original_time
tempmail_module.time.sleep = original_sleep
passed = code == scenario.expected_code
return {
"name": scenario.name,
"anchor_timestamp": anchor_timestamp,
"code": code,
"passed": passed,
"http_calls": len(service.http_client.calls),
"message": scenario.expected_message,
}
def main() -> int:
logging.getLogger("src.services.tempmail").setLevel(logging.ERROR)
scenarios = [
Scenario(
name="anchor_between_two_emails",
anchor_offset_seconds=15,
expected_code="222222",
expected_message="新锚点位于两封邮件之间,第一封应被过滤,第二封应被命中。",
),
Scenario(
name="anchor_set_after_second_email",
anchor_offset_seconds=21,
expected_code=None,
expected_message="锚点晚于第二封邮件时,严格大于过滤会把第二封也排除,复现登录阶段的竞态窗口。",
),
]
print("Tempmail OTP timing check")
print("=========================")
failed = False
for scenario in scenarios:
result = run_scenario(scenario)
status = "PASS" if result["passed"] else "FAIL"
print(f"{status} {result['name']}")
print(f" anchor_timestamp={result['anchor_timestamp']}")
print(f" returned_code={result['code']}")
print(f" inbox_polls={result['http_calls']}")
print(f" note={result['message']}")
if not result["passed"]:
failed = True
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())

103
tests/probe_tempmail.py Normal file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
Tempmail.lol API 探针。
用途:
1. 创建测试收件箱或复用现有 token。
2. 拉取 /inbox 原始 JSON 并原样打印。
3. 检查邮件对象里是否存在 received_at/date 等时间字段。
"""
import argparse
import json
import sys
import time
from typing import Any, Dict, Iterable
import httpx
DEFAULT_BASE_URL = "https://api.tempmail.lol/v2"
TIME_FIELDS = ("received_at", "date", "created_at", "createdAt", "timestamp")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="抓取 Tempmail.lol 收件箱原始 JSON")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Tempmail API 基础地址")
parser.add_argument("--token", help="已有 inbox token未提供时自动创建新邮箱")
parser.add_argument("--poll-count", type=int, default=1, help="轮询次数")
parser.add_argument("--poll-interval", type=float, default=3.0, help="轮询间隔秒数")
parser.add_argument("--timeout", type=float, default=20.0, help="HTTP 超时时间")
return parser.parse_args()
def dump_json(title: str, payload: Dict[str, Any]) -> None:
print(f"\n===== {title} =====")
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
def summarize_time_fields(emails: Iterable[Dict[str, Any]]) -> None:
for index, message in enumerate(emails, start=1):
present_fields = {name: message.get(name) for name in TIME_FIELDS if name in message}
print(f"email[{index}] 时间字段: {json.dumps(present_fields, ensure_ascii=False, default=str)}")
def create_inbox(client: httpx.Client, base_url: str) -> Dict[str, Any]:
response = client.post(
f"{base_url}/inbox/create",
headers={
"Accept": "application/json",
"Content-Type": "application/json",
},
json={},
)
print(f"CREATE_STATUS {response.status_code}")
response.raise_for_status()
payload = response.json()
dump_json("CREATE_RESPONSE", payload)
return payload
def fetch_inbox(client: httpx.Client, base_url: str, token: str) -> Dict[str, Any]:
response = client.get(
f"{base_url}/inbox",
params={"token": token},
headers={"Accept": "application/json"},
)
print(f"INBOX_STATUS {response.status_code}")
response.raise_for_status()
payload = response.json()
dump_json("INBOX_RESPONSE", payload)
emails = payload.get("emails", []) if isinstance(payload, dict) else []
if isinstance(emails, list):
summarize_time_fields([mail for mail in emails if isinstance(mail, dict)])
else:
print(f"emails 字段不是列表: {type(emails).__name__}")
return payload
def main() -> int:
args = parse_args()
with httpx.Client(timeout=args.timeout) as client:
token = args.token
if not token:
inbox = create_inbox(client, args.base_url)
token = str(inbox.get("token", "")).strip()
address = str(inbox.get("address", "")).strip()
print(f"ADDRESS {address}")
print(f"TOKEN {token}")
if not token:
print("未拿到 token无法继续拉取 inbox", file=sys.stderr)
return 1
for attempt in range(1, args.poll_count + 1):
print(f"\n----- poll {attempt}/{args.poll_count} -----")
fetch_inbox(client, args.base_url, token)
if attempt < args.poll_count:
time.sleep(args.poll_interval)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,45 @@
from src.services import EmailServiceType
from src.services.base import BaseEmailService
class DummyEmailService(BaseEmailService):
def __init__(self):
super().__init__(EmailServiceType.TEMPMAIL, name="dummy")
def create_email(self, config=None):
return {"email": "dummy@example.com", "service_id": "dummy"}
def get_verification_code(self, **kwargs):
return None
def list_emails(self, **kwargs):
return []
def delete_email(self, email_id: str) -> bool:
return True
def check_health(self) -> bool:
return True
def test_is_openai_candidate_message_supports_sender_and_content_paths():
service = DummyEmailService()
assert service._is_openai_candidate_message("noreply@openai.com", "hello") is True
assert service._is_openai_candidate_message("notice@example.com", "Your OpenAI verification code") is True
assert service._is_openai_candidate_message("notice@example.com", "newsletter") is False
def test_batch_has_openai_sender_only_checks_sender_fields():
service = DummyEmailService()
batch = [
{"from": "notice@example.com", "body": "openai mentioned in content"},
{"from": "alerts@example.com", "body": "still not sender"},
]
assert service._batch_has_openai_sender(batch, lambda item: item.get("from")) is False
assert service._batch_has_openai_sender(
batch + [{"from": "otp@tm1.openai.com", "body": "code"}],
lambda item: item.get("from"),
) is True

View File

@@ -0,0 +1,127 @@
from src.services import EmailServiceType
from src.services.outlook.base import EmailMessage, ProviderType
from src.services.outlook.email_parser import EmailParser
from src.services.outlook.health_checker import HealthChecker
from src.services.outlook.service import OutlookService
from src.services.base import OTPNoOpenAISenderEmailServiceError
from src.web.routes import registration as registration_routes
def test_health_checker_is_scoped_by_account_email():
checker = HealthChecker(failure_threshold=1, disable_duration=120)
checker.record_failure(ProviderType.IMAP_OLD, "boom", account_email="a@example.com")
assert checker.is_available(ProviderType.IMAP_OLD, "b@example.com") is True
assert checker.is_available(ProviderType.IMAP_OLD, "a@example.com") is False
account_a_status = checker.get_all_health_status("a@example.com")
assert account_a_status[ProviderType.IMAP_OLD.value]["status"] == "disabled"
def test_email_parser_respects_recipient_match_switch():
parser = EmailParser()
mail = EmailMessage(
id="m1",
subject="Your verification code",
sender="noreply@openai.com",
recipients=["other@example.com"],
body="Your code is 123456",
received_timestamp=123,
)
assert parser.is_openai_verification_email(
mail,
target_email="target@example.com",
require_recipient_match=True,
) is False
assert parser.is_openai_verification_email(
mail,
target_email="target@example.com",
require_recipient_match=False,
) is True
assert parser.find_verification_code_in_emails(
[mail],
target_email="target@example.com",
require_recipient_match=True,
) is None
assert parser.find_verification_code_in_emails(
[mail],
target_email="target@example.com",
require_recipient_match=False,
) == "123456"
def test_normalize_outlook_config_inherits_global_settings(monkeypatch):
class DummySettings:
outlook_provider_priority = ["imap_old", "graph_api"]
outlook_health_failure_threshold = 4
outlook_health_disable_duration = 90
outlook_require_recipient_match = True
monkeypatch.setattr(registration_routes, "get_settings", lambda: DummySettings())
normalized = registration_routes._normalize_email_service_config(
EmailServiceType.OUTLOOK,
{
"email": "user@example.com",
"require_recipient_match": False,
},
proxy_url="http://127.0.0.1:7890",
)
assert normalized["provider_priority"] == ["imap_old", "graph_api"]
assert normalized["health_failure_threshold"] == 4
assert normalized["health_disable_duration"] == 90
assert normalized["require_recipient_match"] is False
assert normalized["proxy_url"] == "http://127.0.0.1:7890"
def test_email_parser_has_openai_sender():
parser = EmailParser()
mails = [
EmailMessage(id="x1", subject="hello", sender="notice@example.com"),
EmailMessage(id="x2", subject="otp", sender="no-reply@tm.openai.com"),
]
assert parser.has_openai_sender(mails) is True
def test_outlook_service_returns_early_when_batch_has_no_openai_sender(monkeypatch):
service = OutlookService(
{
"email": "user@example.com",
"password": "pwd",
},
name="outlook-test",
)
non_openai_emails = [
EmailMessage(
id="m1",
subject="newsletter",
sender="newsletter@example.com",
recipients=["user@example.com"],
body="no code",
received_timestamp=100,
)
]
monkeypatch.setattr(
service,
"_try_providers_for_emails",
lambda *args, **kwargs: non_openai_emails,
)
try:
service.get_verification_code(
email="user@example.com",
timeout=30,
otp_sent_at=0,
)
except OTPNoOpenAISenderEmailServiceError:
return
raise AssertionError("expected OTPNoOpenAISenderEmailServiceError")

View File

@@ -2,6 +2,7 @@ import src.core.register as register_module
from src.core.register import (
ERROR_OTP_TIMEOUT_SECONDARY,
PhaseContext,
PhaseResult,
RegistrationEngine,
)
from src.services import EmailServiceType
@@ -13,6 +14,10 @@ class DummySettings:
openai_token_url = "https://token.example.test"
openai_redirect_uri = "https://callback.example.test"
openai_scope = "openid profile email"
email_code_timeout = 120
email_code_poll_interval = 3
email_code_resend_max_retries = 2
email_code_non_openai_sender_resend_max_retries = 1
class FakeEmailService:
@@ -26,6 +31,12 @@ class FakeEmailService:
return self.code
class FastResendEmailService(FakeEmailService):
def get_verification_code(self, **kwargs):
self.calls.append(kwargs)
raise register_module.OTPNoOpenAISenderEmailServiceError()
class FakeCookies:
def __init__(self, values):
self.values = values
@@ -56,11 +67,67 @@ class FakeResponse:
return self._json_payload
def _build_engine(monkeypatch, email_service):
monkeypatch.setattr(register_module, "get_settings", lambda: DummySettings())
def _build_engine(monkeypatch, email_service, **setting_overrides):
def _settings():
settings = DummySettings()
for key, value in setting_overrides.items():
setattr(settings, key, value)
return settings
monkeypatch.setattr(register_module, "get_settings", _settings)
return RegistrationEngine(email_service=email_service)
def _build_failed_phase(error_code: str, error_message: str) -> PhaseResult:
return PhaseResult(
phase=register_module.PHASE_OTP_SECONDARY,
success=False,
error_code=error_code,
error_message=error_message,
retryable=True,
next_action="resend_otp",
)
def _prepare_engine_for_run(monkeypatch, phase_results, **setting_overrides):
engine = _build_engine(
monkeypatch,
FakeEmailService(code=None),
**setting_overrides,
)
monkeypatch.setattr(register_module.time, "time", lambda: 100.0)
send_calls = []
phase_iter = iter(phase_results)
def fake_phase_email_prepare():
engine.email = "tester@example.com"
engine.email_info = {"service_id": "svc-1"}
return True
monkeypatch.setattr(engine, "_phase_email_prepare", fake_phase_email_prepare)
monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US"))
monkeypatch.setattr(engine, "_init_session", lambda: True)
monkeypatch.setattr(engine, "_start_oauth", lambda: True)
monkeypatch.setattr(engine, "_get_device_id", lambda: "did-1")
monkeypatch.setattr(engine, "_check_sentinel", lambda _did: None)
monkeypatch.setattr(
engine,
"_submit_signup_form",
lambda _did, _sen_token: type("SignupResult", (), {"success": True, "error_message": ""})(),
)
monkeypatch.setattr(engine, "_register_password", lambda: (True, "pass-123"))
def fake_send_verification_code():
send_calls.append("send")
engine._otp_sent_at = 100.0
return True
monkeypatch.setattr(engine, "_send_verification_code", fake_send_verification_code)
monkeypatch.setattr(engine, "_phase_otp_secondary", lambda *_args, **_kwargs: (None, next(phase_iter)))
return engine, send_calls
def test_phase_otp_secondary_uses_remaining_budget_from_start_timestamp(monkeypatch):
email_service = FakeEmailService(code="654321")
engine = _build_engine(monkeypatch, email_service)
@@ -101,6 +168,82 @@ def test_phase_otp_secondary_returns_dedicated_timeout_error_code(monkeypatch):
assert engine.phase_history[0].error_code == ERROR_OTP_TIMEOUT_SECONDARY
def test_phase_otp_secondary_maps_no_openai_sender_to_resend_action(monkeypatch):
email_service = FastResendEmailService(code=None)
engine = _build_engine(monkeypatch, email_service)
engine.email = "tester@example.com"
engine.email_info = {"service_id": "svc-1"}
monkeypatch.setattr(register_module.time, "time", lambda: 120.0)
code, phase_result = engine._phase_otp_secondary(
PhaseContext(otp_sent_at=80.0),
started_at=100.0,
)
assert code is None
assert phase_result.success is False
assert phase_result.error_code == "OTP_NO_OPENAI_SENDER"
assert phase_result.retryable is True
assert phase_result.next_action == "resend_otp"
def test_run_uses_dedicated_budget_for_non_openai_sender_resends(monkeypatch):
engine, send_calls = _prepare_engine_for_run(
monkeypatch,
[
_build_failed_phase("OTP_NO_OPENAI_SENDER", "detected non-openai sender"),
_build_failed_phase(ERROR_OTP_TIMEOUT_SECONDARY, "timeout after dedicated resend"),
],
email_code_resend_max_retries=0,
email_code_non_openai_sender_resend_max_retries=1,
)
result = engine.run()
assert result.success is False
assert result.error_code == ERROR_OTP_TIMEOUT_SECONDARY
assert len(send_calls) == 2
def test_run_stops_when_non_openai_sender_budget_is_exhausted(monkeypatch):
engine, send_calls = _prepare_engine_for_run(
monkeypatch,
[
_build_failed_phase("OTP_NO_OPENAI_SENDER", "detected non-openai sender"),
_build_failed_phase("OTP_NO_OPENAI_SENDER", "detected non-openai sender again"),
],
email_code_resend_max_retries=2,
email_code_non_openai_sender_resend_max_retries=1,
)
result = engine.run()
assert result.success is False
assert result.error_code == "OTP_NO_OPENAI_SENDER"
assert len(send_calls) == 2
def test_run_keeps_timeout_budget_after_non_openai_sender_resend(monkeypatch):
engine, send_calls = _prepare_engine_for_run(
monkeypatch,
[
_build_failed_phase(ERROR_OTP_TIMEOUT_SECONDARY, "timeout #1"),
_build_failed_phase("OTP_NO_OPENAI_SENDER", "detected non-openai sender"),
_build_failed_phase(ERROR_OTP_TIMEOUT_SECONDARY, "timeout #2"),
_build_failed_phase(ERROR_OTP_TIMEOUT_SECONDARY, "timeout #3"),
],
email_code_resend_max_retries=2,
email_code_non_openai_sender_resend_max_retries=1,
)
result = engine.run()
assert result.success is False
assert result.error_code == ERROR_OTP_TIMEOUT_SECONDARY
assert len(send_calls) == 4
def test_advance_login_authorization_sets_otp_anchor_before_password_submit(monkeypatch):
email_service = FakeEmailService(code=None)
engine = _build_engine(monkeypatch, email_service)