fix: 扩展邮箱验证码去重到现有账号链路

This commit is contained in:
zhoukailian
2026-03-26 14:34:11 +08:00
parent eda7cbc71f
commit 1bc53b6569
8 changed files with 475 additions and 8 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

@@ -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

View File

@@ -316,7 +316,17 @@ 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

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

View File

@@ -1565,6 +1565,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):
"""查询账号邮箱收件箱最新验证码"""
@@ -1586,6 +1609,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,
@@ -1597,4 +1624,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

@@ -325,15 +325,15 @@ def test_duck_mail_service_skips_previously_used_code_even_with_small_timestamp_
),
FakeResponse(
payload={
"id": "msg-1",
"text": "Your OpenAI verification code is 111111",
"id": "msg-2",
"text": "Your OpenAI verification code is 654321",
"html": [],
}
),
FakeResponse(
payload={
"id": "msg-2",
"text": "Your OpenAI verification code is 654321",
"id": "msg-1",
"text": "Your OpenAI verification code is 111111",
"html": [],
}
),

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"