Merge pull request #106 from ZHOUKAILIAN/fix/issue-80-master

fix: 修复导入邮箱和现有账号场景下重复命中旧验证码的问题
This commit is contained in:
演变
2026-03-27 20:57:38 +08:00
committed by GitHub
12 changed files with 1285 additions and 9 deletions

View File

@@ -1681,6 +1681,10 @@ class RegistrationEngine:
try:
# 获取默认 client_id
settings = get_settings()
metadata = dict(result.metadata or {})
verification_state = self.email_service.export_verification_state(result.email or self.email)
if verification_state["used_codes"] or verification_state["seen_messages"]:
metadata["verification_state"] = verification_state
with get_db() as db:
# 保存账户信息
@@ -1699,7 +1703,7 @@ class RegistrationEngine:
id_token=result.id_token,
cookies=result.cookies,
proxy_used=self.proxy_url,
extra_data=result.metadata,
extra_data=metadata,
source=result.source
)

View File

@@ -8,6 +8,7 @@ import logging
import re
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, Dict, Any, List
from enum import Enum
@@ -146,6 +147,8 @@ class BaseEmailService(abc.ABC):
self._status = EmailServiceStatus.HEALTHY
self._last_error = None
self._provider_backoff = reset_adaptive_backoff()
self._used_verification_codes: Dict[str, set] = {}
self._seen_verification_messages: Dict[str, set] = {}
_EMAIL_ADDRESS_PATTERN = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
@@ -299,6 +302,140 @@ class BaseEmailService(abc.ABC):
return None
def _get_used_verification_codes(self, email: str) -> set:
"""获取邮箱对应的已使用验证码集合。"""
key = str(email or "").strip().lower()
if key not in self._used_verification_codes:
self._used_verification_codes[key] = set()
return self._used_verification_codes[key]
def _get_seen_verification_messages(self, email: str) -> set:
"""获取邮箱对应的已处理消息标识集合。"""
key = str(email or "").strip().lower()
if key not in self._seen_verification_messages:
self._seen_verification_messages[key] = set()
return self._seen_verification_messages[key]
def load_verification_state(
self,
email: str,
used_codes: Optional[List[str]] = None,
seen_messages: Optional[List[str]] = None,
) -> None:
"""将持久化的验证码状态恢复到当前服务实例。"""
if used_codes:
self._get_used_verification_codes(email).update(
str(code) for code in used_codes if code
)
if seen_messages:
self._get_seen_verification_messages(email).update(
str(marker) for marker in seen_messages if marker
)
def export_verification_state(self, email: str) -> Dict[str, List[str]]:
"""导出当前邮箱的验证码状态,用于跨请求复用。"""
return {
"used_codes": sorted(self._get_used_verification_codes(email)),
"seen_messages": sorted(self._get_seen_verification_messages(email)),
}
def _remember_verification_code(self, email: str, code: str) -> bool:
"""记录验证码;若已用过则返回 False。"""
used_codes = self._get_used_verification_codes(email)
if code in used_codes:
return False
used_codes.add(code)
return True
def _remember_verification_message(self, email: str, message_marker: Optional[str]) -> bool:
"""记录消息标识;若已处理过则返回 False。"""
if not message_marker:
return True
seen_messages = self._get_seen_verification_messages(email)
if message_marker in seen_messages:
return False
seen_messages.add(message_marker)
return True
def _accept_verification_code(
self,
email: str,
code: str,
message_marker: Optional[str] = None,
) -> bool:
"""
决定是否接受验证码。
若有可靠的新邮件标识,优先按消息去重,这样新邮件即便验证码重复也能被接受;
否则退回到按验证码去重,避免旧码被重复消费。
"""
if message_marker:
if not self._remember_verification_message(email, message_marker):
return False
self._get_used_verification_codes(email).add(code)
return True
return self._remember_verification_code(email, code)
def _parse_message_timestamp(self, value: Any) -> Optional[float]:
"""将常见邮件时间字段解析为 Unix 时间戳。"""
if value is None or value == "":
return None
if isinstance(value, datetime):
return value.timestamp()
if isinstance(value, (int, float)):
return self._normalize_unix_timestamp(float(value))
text = str(value).strip()
if not text:
return None
try:
return self._normalize_unix_timestamp(float(text))
except ValueError:
pass
normalized = text.replace("Z", "+00:00") if text.endswith("Z") else text
try:
return datetime.fromisoformat(normalized).timestamp()
except ValueError:
return None
def _normalize_unix_timestamp(self, value: float) -> float:
"""将秒/毫秒/微秒级 Unix 时间统一归一到秒。"""
absolute = abs(value)
if absolute >= 1e14:
return value / 1_000_000
if absolute >= 1e11:
return value / 1_000
return value
def _is_message_before_otp(self, message_time: Any, otp_sent_at: Optional[float], tolerance_seconds: int = 1) -> bool:
"""
判断邮件是否早于当前 OTP 发送窗口。
允许少量时钟误差,避免接口时间与本地时间有轻微偏移时误伤新邮件。
"""
if not otp_sent_at:
return False
message_ts = self._parse_message_timestamp(message_time)
if message_ts is None:
return False
return message_ts + tolerance_seconds < otp_sent_at
def _sort_items_by_message_time(self, items: List[Any], value_getter) -> List[Any]:
"""按邮件时间倒序排列,优先处理最新邮件。"""
return sorted(
items,
key=lambda item: self._parse_message_timestamp(value_getter(item)) or float("-inf"),
reverse=True,
)
def wait_for_email(
self,
email: str,

View File

@@ -271,7 +271,12 @@ class DuckMailService(BaseEmailService):
)
messages = response.get("hydra:member", [])
for message in messages:
ordered_messages = self._sort_items_by_message_time(
messages,
lambda item: item.get("createdAt") if isinstance(item, dict) else None,
)
for message in ordered_messages:
message_id = str(message.get("id") or "").strip()
if not message_id or message_id in seen_message_ids:
continue
@@ -281,6 +286,7 @@ class DuckMailService(BaseEmailService):
continue
seen_message_ids.add(message_id)
message_marker = f"id:{message_id}"
detail = self._make_request(
"GET",
f"/messages/{message_id}",
@@ -293,8 +299,11 @@ class DuckMailService(BaseEmailService):
match = re.search(pattern, content)
if match:
code = match.group(1)
if not self._accept_verification_code(email, code, message_marker):
continue
self.update_status(True)
return match.group(1)
return code
except Exception as e:
logger.debug(f"DuckMail 轮询验证码失败: {e}")

View File

@@ -221,12 +221,29 @@ class FreemailService(BaseEmailService):
time.sleep(3)
continue
for mail in mails:
ordered_mails = self._sort_items_by_message_time(
mails,
lambda item: (
item.get("created_at")
or item.get("createdAt")
or item.get("received_at")
or item.get("receivedAt")
) if isinstance(item, dict) else None,
)
for mail in ordered_mails:
mail_id = mail.get("id")
if not mail_id or mail_id in seen_mail_ids:
continue
seen_mail_ids.add(mail_id)
message_marker = f"id:{mail_id}"
if self._is_message_before_otp(
mail.get("created_at") or mail.get("createdAt") or mail.get("received_at") or mail.get("receivedAt"),
otp_sent_at,
):
continue
sender = str(mail.get("sender", "")).lower()
subject = str(mail.get("subject", ""))
@@ -239,25 +256,30 @@ class FreemailService(BaseEmailService):
code = self._extract_otp_from_text(content, pattern)
if code:
if not self._accept_verification_code(email, code, message_marker):
continue
logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {code}")
self.update_status(True)
return code
v_code = str(mail.get("verification_code") or "").strip()
# 如果依然未找到,获取邮件详情进行匹配
try:
detail = self._make_request("GET", f"/api/email/{mail_id}")
full_content = str(detail.get("content", "")) + "\n" + str(detail.get("html_content", ""))
code = self._extract_otp_from_text(full_content, pattern)
if code:
if not self._accept_verification_code(email, code, message_marker):
continue
logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {code}")
self.update_status(True)
return code
except Exception as e:
logger.debug(f"获取 Freemail 邮件详情失败: {e}")
v_code = str(mail.get("verification_code") or "").strip()
if re.fullmatch(r"\d{6}", v_code):
if not self._accept_verification_code(email, v_code, message_marker):
continue
logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {v_code}")
self.update_status(True)
return v_code

View File

@@ -316,12 +316,29 @@ class MeoMailEmailService(BaseEmailService):
time.sleep(3)
continue
for message in messages:
ordered_messages = self._sort_items_by_message_time(
messages,
lambda item: (
item.get("created_at")
or item.get("createdAt")
or item.get("received_at")
or item.get("receivedAt")
) if isinstance(item, dict) else None,
)
for message in ordered_messages:
message_id = message.get("id")
if not message_id or message_id in seen_message_ids:
continue
seen_message_ids.add(message_id)
message_marker = f"id:{message_id}"
if self._is_message_before_otp(
message.get("created_at") or message.get("createdAt") or message.get("received_at") or message.get("receivedAt"),
otp_sent_at,
):
continue
# 检查是否是目标邮件
sender = str(message.get("from_address", "")).lower()
@@ -343,6 +360,8 @@ class MeoMailEmailService(BaseEmailService):
match = re.search(pattern, re.sub(email_pattern, "", content))
if match:
code = match.group(1)
if not self._accept_verification_code(email, code, message_marker):
continue
logger.info(f"从自定义域名邮箱 {email} 找到验证码: {code}")
self.update_status(True)
return code

View File

@@ -335,12 +335,29 @@ class TempMailService(BaseEmailService):
time.sleep(3)
continue
for mail in mails:
ordered_mails = self._sort_items_by_message_time(
mails,
lambda item: (
item.get("createdAt")
or item.get("created_at")
or item.get("receivedAt")
or item.get("received_at")
) if isinstance(item, dict) else None,
)
for mail in ordered_mails:
mail_id = mail.get("id")
if not mail_id or mail_id in seen_mail_ids:
continue
seen_mail_ids.add(mail_id)
message_marker = f"id:{mail_id}"
if self._is_message_before_otp(
mail.get("createdAt") or mail.get("created_at") or mail.get("receivedAt") or mail.get("received_at"),
otp_sent_at,
):
continue
parsed = self._extract_mail_fields(mail)
sender = parsed["sender"].lower()
@@ -355,6 +372,8 @@ class TempMailService(BaseEmailService):
code = self._extract_otp_from_text(content, pattern)
if code:
if not self._accept_verification_code(email, code, message_marker):
continue
logger.info(f"从 TempMail 邮箱 {email} 找到验证码: {code}")
self.update_status(True)
return code

View File

@@ -241,7 +241,12 @@ class TempmailService(BaseEmailService):
time.sleep(3)
continue
for msg in email_list:
ordered_emails = self._sort_items_by_message_time(
email_list,
lambda item: item.get("date") if isinstance(item, dict) else None,
)
for msg in ordered_emails:
if not isinstance(msg, dict):
continue
@@ -260,6 +265,7 @@ class TempmailService(BaseEmailService):
if not message_id or message_id in seen_ids:
continue
seen_ids.add(message_id)
message_marker = f"id:{message_id}"
sender = str(msg.get("from", "")).lower()
subject = str(msg.get("subject", ""))
@@ -276,6 +282,8 @@ class TempmailService(BaseEmailService):
match = re.search(pattern, content)
if match:
code = match.group(1)
if not self._accept_verification_code(email, code, message_marker):
continue
logger.info(f"找到验证码: {code}")
self.update_status(True)
return code

View File

@@ -1566,6 +1566,29 @@ def _build_inbox_config(db, service_type, email: str) -> dict:
return cfg
def _load_account_verification_state(account: Account) -> dict:
"""从账号扩展信息中读取验证码去重状态。"""
extra = account.extra_data or {}
state = extra.get("verification_state") if isinstance(extra, dict) else {}
if not isinstance(state, dict):
state = {}
return {
"used_codes": [str(code) for code in (state.get("used_codes") or []) if code],
"seen_messages": [str(marker) for marker in (state.get("seen_messages") or []) if marker],
}
def _save_account_verification_state(db, account: Account, service) -> None:
"""将当前收件箱消费状态持久化到账号表,支持跨请求延续。"""
state = service.export_verification_state(account.email)
if not state["used_codes"] and not state["seen_messages"]:
return
extra = dict(account.extra_data or {})
extra["verification_state"] = state
crud.update_account(db, account.id, extra_data=extra)
@router.post("/{account_id}/inbox-code")
async def get_account_inbox_code(account_id: int):
"""查询账号邮箱收件箱最新验证码"""
@@ -1587,6 +1610,10 @@ async def get_account_inbox_code(account_id: int):
try:
svc = EmailServiceFactory.create(service_type, config)
svc.load_verification_state(
account.email,
**_load_account_verification_state(account),
)
code = svc.get_verification_code(
account.email,
email_id=account.email_service_id,
@@ -1598,4 +1625,6 @@ async def get_account_inbox_code(account_id: int):
if not code:
return {"success": False, "error": "未收到验证码邮件"}
_save_account_verification_state(db, account, svc)
return {"success": True, "code": code, "email": account.email}

View File

@@ -0,0 +1,156 @@
import asyncio
from contextlib import contextmanager
from pathlib import Path
from src.config.constants import EmailServiceType
from src.core.register import RegistrationEngine, RegistrationResult
from src.database.models import Account, Base
from src.database.session import DatabaseSessionManager
from src.services.base import BaseEmailService
from src.web.routes import accounts as accounts_routes
class DummySettings:
openai_client_id = "client-1"
openai_auth_url = "https://auth.openai.test/authorize"
openai_token_url = "https://auth.openai.test/token"
openai_redirect_uri = "https://localhost/callback"
openai_scope = "openid profile email offline_access"
tempmail_base_url = "https://api.tempmail.test"
tempmail_timeout = 30
tempmail_max_retries = 3
class FakeStatefulTempmailService(BaseEmailService):
def __init__(self, config=None, name=None):
super().__init__(EmailServiceType.TEMPMAIL, name)
self.messages = [
("id:msg-1", "111111"),
("id:msg-2", "222222"),
]
def create_email(self, config=None):
return {"email": "tester@example.com", "service_id": "token-1"}
def get_verification_code(
self,
email: str,
email_id: str = None,
timeout: int = 120,
pattern: str = r"(?<!\d)(\d{6})(?!\d)",
otp_sent_at=None,
):
for marker, code in self.messages:
if self._accept_verification_code(email, code, marker):
return code
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 _build_test_db(name: str) -> DatabaseSessionManager:
runtime_dir = Path("tests_runtime")
runtime_dir.mkdir(exist_ok=True)
db_path = runtime_dir / name
if db_path.exists():
db_path.unlink()
manager = DatabaseSessionManager(f"sqlite:///{db_path}")
Base.metadata.create_all(bind=manager.engine)
return manager
def test_account_inbox_code_persists_verification_state_across_requests(monkeypatch):
manager = _build_test_db("account_inbox_code_state.db")
with manager.session_scope() as session:
account = Account(
email="tester@example.com",
email_service="tempmail",
email_service_id="token-1",
status="active",
extra_data={},
)
session.add(account)
session.commit()
session.refresh(account)
account_id = account.id
@contextmanager
def fake_get_db():
session = manager.SessionLocal()
try:
yield session
finally:
session.close()
monkeypatch.setattr(accounts_routes, "get_db", fake_get_db)
monkeypatch.setattr(accounts_routes, "get_settings", lambda: DummySettings())
monkeypatch.setattr(
"src.services.base.EmailServiceFactory.create",
lambda service_type, config, name=None: FakeStatefulTempmailService(config, name),
)
first = asyncio.run(accounts_routes.get_account_inbox_code(account_id))
second = asyncio.run(accounts_routes.get_account_inbox_code(account_id))
assert first["success"] is True
assert first["code"] == "111111"
assert second["success"] is True
assert second["code"] == "222222"
with manager.session_scope() as session:
saved = session.query(Account).filter(Account.id == account_id).first()
verification_state = (saved.extra_data or {}).get("verification_state") or {}
assert verification_state["used_codes"] == ["111111", "222222"]
assert verification_state["seen_messages"] == ["id:msg-1", "id:msg-2"]
def test_save_to_database_persists_verification_state(monkeypatch):
manager = _build_test_db("registration_verification_state.db")
@contextmanager
def fake_get_db():
session = manager.SessionLocal()
try:
yield session
finally:
session.close()
monkeypatch.setattr("src.core.register.get_db", fake_get_db)
monkeypatch.setattr("src.core.register.get_settings", lambda: DummySettings())
email_service = FakeStatefulTempmailService()
email_service._accept_verification_code("tester@example.com", "111111", "id:msg-1")
engine = RegistrationEngine(email_service=email_service, proxy_url="http://proxy.test")
engine.email_info = {"service_id": "token-1"}
result = RegistrationResult(
success=True,
email="tester@example.com",
password="secret",
account_id="acct-1",
workspace_id="ws-1",
access_token="access-token",
refresh_token="refresh-token",
id_token="id-token",
session_token="session-token",
metadata={"registered_at": "2026-03-26T00:00:00"},
source="register",
)
assert engine.save_to_database(result) is True
with manager.session_scope() as session:
saved = session.query(Account).filter(Account.email == "tester@example.com").first()
verification_state = (saved.extra_data or {}).get("verification_state") or {}
assert verification_state["used_codes"] == ["111111"]
assert verification_state["seen_messages"] == ["id:msg-1"]

View File

@@ -0,0 +1,523 @@
from src.services.duck_mail import DuckMailService
from src.services.freemail import FreemailService
from src.services.moe_mail import MeoMailEmailService
from src.services.temp_mail import TempMailService
from src.services.tempmail import TempmailService
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text
self.headers = {}
def json(self):
if self._payload is None:
raise ValueError("no json payload")
return self._payload
class FakeRequestHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def request(self, method, url, **kwargs):
self.calls.append({
"method": method,
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"未准备响应: {method} {url}")
return self.responses.pop(0)
class FakeGetHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def get(self, url, **kwargs):
self.calls.append({
"method": "GET",
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"未准备响应: GET {url}")
return self.responses.pop(0)
def test_tempmail_service_skips_code_returned_by_previous_fetch():
service = TempmailService({"base_url": "https://api.tempmail.test"})
service.http_client = FakeGetHTTPClient([
FakeResponse(
payload={
"emails": [
{
"date": 1000,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
}
]
}
),
FakeResponse(
payload={
"emails": [
{
"date": 1000,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
},
{
"date": 1003,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 654321",
},
]
}
),
])
first_code = service.get_verification_code(
email="tester@example.com",
email_id="token-1",
timeout=1,
otp_sent_at=1000,
)
second_code = service.get_verification_code(
email="tester@example.com",
email_id="token-1",
timeout=1,
otp_sent_at=1002,
)
assert first_code == "111111"
assert second_code == "654321"
def test_temp_mail_service_skips_code_returned_by_previous_fetch():
service = TempMailService({
"base_url": "https://mail.example.com",
"admin_password": "admin-secret",
"domain": "example.com",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
}
]
}
),
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 654321",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
])
first_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378400,
)
second_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378402,
)
assert first_code == "111111"
assert second_code == "654321"
def test_temp_mail_service_accepts_same_code_from_newer_message():
service = TempMailService({
"base_url": "https://mail.example.com",
"admin_password": "admin-secret",
"domain": "example.com",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
}
]
}
),
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
])
first_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378400,
)
second_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378402,
)
assert first_code == "111111"
assert second_code == "111111"
def test_freemail_service_skips_code_returned_by_previous_fetch():
service = FreemailService({
"base_url": "https://mail.example.com",
"admin_token": "jwt-token",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload=[
{
"id": "msg-1",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 111111",
"verification_code": "111111",
"created_at": "2026-03-19T10:00:00Z",
}
]
),
FakeResponse(
payload=[
{
"id": "msg-1",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 111111",
"verification_code": "111111",
"created_at": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 654321",
"verification_code": "654321",
"created_at": "2026-03-19T10:00:03Z",
},
]
),
])
first_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378400,
)
second_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378402,
)
assert first_code == "111111"
assert second_code == "654321"
def test_duck_mail_service_skips_previously_used_code_even_with_small_timestamp_gap():
service = DuckMailService({
"base_url": "https://api.duckmail.test",
"default_domain": "duckmail.sbs",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"hydra:member": [
{
"id": "msg-1",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:01Z",
}
]
}
),
FakeResponse(
payload={
"id": "msg-1",
"text": "Your OpenAI verification code is 111111",
"html": [],
}
),
FakeResponse(
payload={
"hydra:member": [
{
"id": "msg-1",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:01Z",
},
{
"id": "msg-2",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
FakeResponse(
payload={
"id": "msg-2",
"text": "Your OpenAI verification code is 654321",
"html": [],
}
),
FakeResponse(
payload={
"id": "msg-1",
"text": "Your OpenAI verification code is 111111",
"html": [],
}
),
])
service._accounts_by_email["tester@duckmail.sbs"] = {
"email": "tester@duckmail.sbs",
"service_id": "account-1",
"account_id": "account-1",
"token": "token-123",
}
first_code = service.get_verification_code(
email="tester@duckmail.sbs",
email_id="account-1",
timeout=1,
otp_sent_at=1742378401,
)
second_code = service.get_verification_code(
email="tester@duckmail.sbs",
email_id="account-1",
timeout=1,
otp_sent_at=1742378402,
)
assert first_code == "111111"
assert second_code == "654321"
def test_moe_mail_service_filters_old_messages_with_millisecond_timestamps():
service = MeoMailEmailService({
"base_url": "https://mail.example.com",
"api_key": "api-key",
})
def fake_make_request(method, endpoint, **kwargs):
if endpoint == "/api/emails/email-1":
return {
"messages": [
{
"id": "msg-old",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378400000,
},
{
"id": "msg-new",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378403000,
},
]
}
if endpoint == "/api/emails/email-1/msg-old":
return {
"message": {
"content": "Your OpenAI verification code is 111111",
}
}
if endpoint == "/api/emails/email-1/msg-new":
return {
"message": {
"content": "Your OpenAI verification code is 654321",
}
}
raise AssertionError(f"未准备响应: {method} {endpoint}")
service._make_request = fake_make_request
code = service.get_verification_code(
email="tester@example.com",
email_id="email-1",
timeout=1,
otp_sent_at=1742378402,
)
assert code == "654321"
def test_moe_mail_service_cross_request_state_prefers_latest_of_three_messages():
first_service = MeoMailEmailService({
"base_url": "https://mail.example.com",
"api_key": "api-key",
})
first_responses = [
{
"messages": [
{
"id": "msg-1",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378400000,
},
]
},
{
"message": {
"content": "Your OpenAI verification code is 111111",
}
},
]
def fake_make_request_first(method, endpoint, **kwargs):
if not first_responses:
raise AssertionError(f"未准备响应: {method} {endpoint}")
return first_responses.pop(0)
first_service._make_request = fake_make_request_first
first_code = first_service.get_verification_code(
email="tester@example.com",
email_id="email-1",
timeout=1,
)
state = first_service.export_verification_state("tester@example.com")
second_service = MeoMailEmailService({
"base_url": "https://mail.example.com",
"api_key": "api-key",
})
second_service.load_verification_state("tester@example.com", **state)
second_calls = []
second_responses = {
"/api/emails/email-1": {
"messages": [
{
"id": "msg-1",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378400000,
},
{
"id": "msg-2",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378403000,
},
{
"id": "msg-3",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378406000,
},
]
},
"/api/emails/email-1/msg-3": {
"message": {
"content": "Your OpenAI verification code is 333333",
}
},
"/api/emails/email-1/msg-2": {
"message": {
"content": "Your OpenAI verification code is 222222",
}
},
"/api/emails/email-1/msg-1": {
"message": {
"content": "Your OpenAI verification code is 111111",
}
},
}
def fake_make_request_second(method, endpoint, **kwargs):
second_calls.append(endpoint)
if endpoint not in second_responses:
raise AssertionError(f"未准备响应: {method} {endpoint}")
return second_responses[endpoint]
second_service._make_request = fake_make_request_second
second_code = second_service.get_verification_code(
email="tester@example.com",
email_id="email-1",
timeout=1,
)
assert first_code == "111111"
assert state == {
"used_codes": ["111111"],
"seen_messages": ["id:msg-1"],
}
assert second_code == "333333"
assert second_calls == [
"/api/emails/email-1",
"/api/emails/email-1/msg-3",
]

View File

@@ -0,0 +1,258 @@
from src.services.duck_mail import DuckMailService
from src.services.freemail import FreemailService
from src.services.moe_mail import MeoMailEmailService
from src.services.temp_mail import TempMailService
from src.services.tempmail import TempmailService
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text
self.headers = {}
def json(self):
if self._payload is None:
raise ValueError("no json payload")
return self._payload
class FakeRequestHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def request(self, method, url, **kwargs):
self.calls.append({
"method": method,
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"未准备响应: {method} {url}")
return self.responses.pop(0)
class FakeGetHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def get(self, url, **kwargs):
self.calls.append({
"method": "GET",
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"未准备响应: GET {url}")
return self.responses.pop(0)
def test_tempmail_service_prefers_latest_matching_message_without_otp_timestamp():
service = TempmailService({"base_url": "https://api.tempmail.test"})
service.http_client = FakeGetHTTPClient([
FakeResponse(
payload={
"emails": [
{
"date": 1000,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
},
{
"date": 1003,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 654321",
},
]
}
),
])
code = service.get_verification_code(
email="tester@example.com",
email_id="token-1",
timeout=1,
)
assert code == "654321"
def test_temp_mail_service_prefers_latest_matching_message_without_otp_timestamp():
service = TempMailService({
"base_url": "https://mail.example.com",
"admin_password": "admin-secret",
"domain": "example.com",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 654321",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
])
code = service.get_verification_code(
email="tester@example.com",
timeout=1,
)
assert code == "654321"
def test_moe_mail_service_prefers_latest_matching_message_without_otp_timestamp():
service = MeoMailEmailService({
"base_url": "https://mail.example.com",
"api_key": "api-key",
})
def fake_make_request(method, endpoint, **kwargs):
if endpoint == "/api/emails/email-1":
return {
"messages": [
{
"id": "msg-1",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378400000,
},
{
"id": "msg-2",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378403000,
},
]
}
if endpoint == "/api/emails/email-1/msg-1":
return {
"message": {
"content": "Your OpenAI verification code is 111111",
}
}
if endpoint == "/api/emails/email-1/msg-2":
return {
"message": {
"content": "Your OpenAI verification code is 654321",
}
}
raise AssertionError(f"未准备响应: {method} {endpoint}")
service._make_request = fake_make_request
code = service.get_verification_code(
email="tester@example.com",
email_id="email-1",
timeout=1,
)
assert code == "654321"
def test_freemail_service_prefers_latest_matching_message_without_otp_timestamp():
service = FreemailService({
"base_url": "https://mail.example.com",
"admin_token": "jwt-token",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload=[
{
"id": "msg-1",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 111111",
"verification_code": "111111",
"created_at": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 654321",
"verification_code": "654321",
"created_at": "2026-03-19T10:00:03Z",
},
]
),
])
code = service.get_verification_code(
email="tester@example.com",
timeout=1,
)
assert code == "654321"
def test_duck_mail_service_prefers_latest_matching_message_without_otp_timestamp():
service = DuckMailService({
"base_url": "https://api.duckmail.test",
"default_domain": "duckmail.sbs",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"hydra:member": [
{
"id": "msg-1",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
FakeResponse(
payload={
"id": "msg-2",
"text": "Your OpenAI verification code is 654321",
"html": [],
}
),
])
service._accounts_by_email["tester@duckmail.sbs"] = {
"email": "tester@duckmail.sbs",
"service_id": "account-1",
"account_id": "account-1",
"token": "token-123",
}
code = service.get_verification_code(
email="tester@duckmail.sbs",
email_id="account-1",
timeout=1,
)
assert code == "654321"

View File

@@ -0,0 +1,92 @@
import json
from types import SimpleNamespace
from src.core.register import RegistrationEngine
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text
self.headers = {}
def json(self):
if self._payload is None:
raise ValueError("no json payload")
return self._payload
class FakeSession:
def __init__(self):
self.get_calls = []
self.post_calls = []
self.cookies = SimpleNamespace(get=lambda name: None)
def get(self, url, **kwargs):
self.get_calls.append({
"url": url,
"kwargs": kwargs,
})
return FakeResponse(status_code=200)
def post(self, url, **kwargs):
self.post_calls.append({
"url": url,
"kwargs": kwargs,
})
if url.endswith("/email-otp/validate"):
code = json.loads(kwargs["data"])["code"]
return FakeResponse(status_code=200 if code == "654321" else 401)
return FakeResponse(status_code=200)
class FakeEmailService:
def __init__(self):
self.calls = []
def get_verification_code(self, **kwargs):
self.calls.append(kwargs)
if len(self.calls) == 1:
return None
return "654321"
def test_registration_engine_resend_flow_propagates_new_otp_timestamp_and_validates_code(monkeypatch):
engine = RegistrationEngine.__new__(RegistrationEngine)
engine.logs = []
engine._log = lambda message, level="info": None
engine.email = "tester@example.com"
engine.email_info = {"service_id": "email-1"}
engine.email_service = FakeEmailService()
engine.session = FakeSession()
engine._otp_sent_at = None
engine.phase_history = []
issued_timestamps = iter([1000.0, 1000.0, 1000.0, 1005.0, 1005.0, 1005.0])
monkeypatch.setattr("src.core.register.time.time", lambda: next(issued_timestamps))
assert engine._send_verification_code() is True
first_otp_sent_at = engine._otp_sent_at
first_code = engine._get_verification_code()
assert first_otp_sent_at == 1000.0
assert first_code is None
assert engine._send_verification_code() is True
second_otp_sent_at = engine._otp_sent_at
second_code = engine._get_verification_code()
assert second_otp_sent_at == 1005.0
assert second_code == "654321"
assert engine._validate_verification_code(second_code) is True
assert len(engine.email_service.calls) == 2
assert engine.email_service.calls[0]["otp_sent_at"] == 1000.0
assert engine.email_service.calls[1]["otp_sent_at"] == 1005.0
assert engine.email_service.calls[0]["email_id"] == "email-1"
assert engine.email_service.calls[1]["email_id"] == "email-1"
validate_call = engine.session.post_calls[-1]
assert validate_call["url"].endswith("/email-otp/validate")
assert json.loads(validate_call["kwargs"]["data"]) == {"code": "654321"}