From ffab473a720f1e8be1b58aeab1840acc29425088 Mon Sep 17 00:00:00 2001 From: zhoukailian Date: Tue, 24 Mar 2026 18:41:57 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E5=8F=96=E7=A0=81=E5=91=BD=E4=B8=AD=E6=97=A7=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/base.py | 137 +++++++++++ src/services/duck_mail.py | 6 +- src/services/freemail.py | 38 ++- src/services/moe_mail.py | 9 + src/services/temp_mail.py | 21 +- src/services/tempmail.py | 3 + tests/test_mail_code_reuse_guard.py | 361 ++++++++++++++++++++++++++++ 7 files changed, 565 insertions(+), 10 deletions(-) create mode 100644 tests/test_mail_code_reuse_guard.py diff --git a/src/services/base.py b/src/services/base.py index 9ea08f6..5c68843 100644 --- a/src/services/base.py +++ b/src/services/base.py @@ -8,6 +8,7 @@ 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 @@ def __init__(self, service_type: EmailServiceType, name: str = None): 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 @@ def _extract_otp_from_text(self, text: str, pattern: Optional[str] = None) -> Op 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, diff --git a/src/services/duck_mail.py b/src/services/duck_mail.py index 0fed9e0..9561a4c 100644 --- a/src/services/duck_mail.py +++ b/src/services/duck_mail.py @@ -281,6 +281,7 @@ def get_verification_code( continue seen_message_ids.add(message_id) + message_marker = f"id:{message_id}" detail = self._make_request( "GET", f"/messages/{message_id}", @@ -293,8 +294,11 @@ def get_verification_code( 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}") diff --git a/src/services/freemail.py b/src/services/freemail.py index 3a02c16..9cd75ca 100644 --- a/src/services/freemail.py +++ b/src/services/freemail.py @@ -221,12 +221,29 @@ def get_verification_code( 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", "")) @@ -237,31 +254,36 @@ def get_verification_code( if "openai" not in content.lower(): continue + 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 + 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}") - if re.fullmatch(r"\d{6}", v_code): - logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {v_code}") - self.update_status(True) - return v_code - except Exception as e: logger.debug(f"检查 Freemail 邮件时出错: {e}") diff --git a/src/services/moe_mail.py b/src/services/moe_mail.py index 00ce26a..90a022c 100644 --- a/src/services/moe_mail.py +++ b/src/services/moe_mail.py @@ -322,6 +322,13 @@ def get_verification_code( 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 +350,8 @@ def get_verification_code( 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 diff --git a/src/services/temp_mail.py b/src/services/temp_mail.py index 9325299..df5e0a7 100644 --- a/src/services/temp_mail.py +++ b/src/services/temp_mail.py @@ -335,12 +335,29 @@ def get_verification_code( 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 @@ def get_verification_code( 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 diff --git a/src/services/tempmail.py b/src/services/tempmail.py index a0a5ca8..1ac1fc7 100644 --- a/src/services/tempmail.py +++ b/src/services/tempmail.py @@ -260,6 +260,7 @@ def get_verification_code( 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 +277,8 @@ def get_verification_code( 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 diff --git a/tests/test_mail_code_reuse_guard.py b/tests/test_mail_code_reuse_guard.py new file mode 100644 index 0000000..0a586b3 --- /dev/null +++ b/tests/test_mail_code_reuse_guard.py @@ -0,0 +1,361 @@ +from src.services.duck_mail import DuckMailService +from src.services.freemail import FreemailService +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 ", + "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 ", + "subject": "Your verification code", + "body": "Your OpenAI verification code is 111111", + "createdAt": "2026-03-19T10:00:00Z", + }, + { + "id": "msg-2", + "source": "OpenAI ", + "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 ", + "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 ", + "subject": "Your verification code", + "body": "Your OpenAI verification code is 111111", + "createdAt": "2026-03-19T10:00:00Z", + }, + { + "id": "msg-2", + "source": "OpenAI ", + "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-1", + "text": "Your OpenAI verification code is 111111", + "html": [], + } + ), + 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", + } + + 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" From e5aad8f1dc5a1b3308756e298f1f81143396ab23 Mon Sep 17 00:00:00 2001 From: zhoukailian Date: Thu, 26 Mar 2026 11:47:14 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=E6=AF=AB=E7=A7=92?= =?UTF-8?q?=E7=BA=A7=E9=82=AE=E4=BB=B6=E6=97=B6=E9=97=B4=E6=88=B3=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E6=97=A7=E9=AA=8C=E8=AF=81=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_mail_code_reuse_guard.py | 51 +++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_mail_code_reuse_guard.py b/tests/test_mail_code_reuse_guard.py index 0a586b3..2bc2d43 100644 --- a/tests/test_mail_code_reuse_guard.py +++ b/tests/test_mail_code_reuse_guard.py @@ -1,5 +1,6 @@ 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 @@ -359,3 +360,53 @@ def test_duck_mail_service_skips_previously_used_code_even_with_small_timestamp_ 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" From eda7cbc71f26cb4c028708f207be7d56b4848077 Mon Sep 17 00:00:00 2001 From: zhoukailian Date: Thu, 26 Mar 2026 14:20:28 +0800 Subject: [PATCH 3/6] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=A0=81=E9=87=8D=E5=8F=91=E9=93=BE=E8=B7=AF=E9=9B=86?= =?UTF-8?q?=E6=88=90=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_register_otp_integration.py | 91 ++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/test_register_otp_integration.py diff --git a/tests/test_register_otp_integration.py b/tests/test_register_otp_integration.py new file mode 100644 index 0000000..e291c40 --- /dev/null +++ b/tests/test_register_otp_integration.py @@ -0,0 +1,91 @@ +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 + + issued_timestamps = iter([1000.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"} From 1bc53b6569d06d52ae0f6b314247cf8981d9dc74 Mon Sep 17 00:00:00 2001 From: zhoukailian Date: Thu, 26 Mar 2026 14:34:11 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E6=89=A9=E5=B1=95=E9=82=AE=E7=AE=B1?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=E5=8E=BB=E9=87=8D=E5=88=B0=E7=8E=B0?= =?UTF-8?q?=E6=9C=89=E8=B4=A6=E5=8F=B7=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/register.py | 6 +- src/services/duck_mail.py | 7 +- src/services/moe_mail.py | 12 +- src/services/tempmail.py | 7 +- src/web/routes/accounts.py | 29 +++ tests/test_account_inbox_code_state.py | 156 +++++++++++++ tests/test_mail_code_reuse_guard.py | 8 +- tests/test_mail_latest_code_preference.py | 258 ++++++++++++++++++++++ 8 files changed, 475 insertions(+), 8 deletions(-) create mode 100644 tests/test_account_inbox_code_state.py create mode 100644 tests/test_mail_latest_code_preference.py diff --git a/src/core/register.py b/src/core/register.py index d3cdf75..b8c2bb5 100644 --- a/src/core/register.py +++ b/src/core/register.py @@ -1681,6 +1681,10 @@ def save_to_database(self, result: RegistrationResult) -> bool: 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 @@ def save_to_database(self, result: RegistrationResult) -> bool: id_token=result.id_token, cookies=result.cookies, proxy_used=self.proxy_url, - extra_data=result.metadata, + extra_data=metadata, source=result.source ) diff --git a/src/services/duck_mail.py b/src/services/duck_mail.py index 9561a4c..815b82f 100644 --- a/src/services/duck_mail.py +++ b/src/services/duck_mail.py @@ -271,7 +271,12 @@ def get_verification_code( ) 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 diff --git a/src/services/moe_mail.py b/src/services/moe_mail.py index 90a022c..23bb123 100644 --- a/src/services/moe_mail.py +++ b/src/services/moe_mail.py @@ -316,7 +316,17 @@ def get_verification_code( 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 diff --git a/src/services/tempmail.py b/src/services/tempmail.py index 1ac1fc7..9e317d6 100644 --- a/src/services/tempmail.py +++ b/src/services/tempmail.py @@ -241,7 +241,12 @@ def get_verification_code( 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 diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 7e4e4d5..53d420a 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -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} diff --git a/tests/test_account_inbox_code_state.py b/tests/test_account_inbox_code_state.py new file mode 100644 index 0000000..5f3dcd6 --- /dev/null +++ b/tests/test_account_inbox_code_state.py @@ -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"(? 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"] diff --git a/tests/test_mail_code_reuse_guard.py b/tests/test_mail_code_reuse_guard.py index 2bc2d43..9f30698 100644 --- a/tests/test_mail_code_reuse_guard.py +++ b/tests/test_mail_code_reuse_guard.py @@ -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": [], } ), diff --git a/tests/test_mail_latest_code_preference.py b/tests/test_mail_latest_code_preference.py new file mode 100644 index 0000000..4eee104 --- /dev/null +++ b/tests/test_mail_latest_code_preference.py @@ -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 ", + "subject": "Your verification code", + "body": "Your OpenAI verification code is 111111", + "createdAt": "2026-03-19T10:00:00Z", + }, + { + "id": "msg-2", + "source": "OpenAI ", + "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" From c210d2c78fc70c59fce7180d7a401e2b00027a9f Mon Sep 17 00:00:00 2001 From: zhoukailian Date: Thu, 26 Mar 2026 23:52:11 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E5=AF=B9=E9=BD=90=20master=20?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E4=B8=8B=E7=9A=84=20Freemail=20=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=E4=B8=8E=20OTP=20=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/freemail.py | 16 ++++++++-------- tests/test_register_otp_integration.py | 3 ++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/services/freemail.py b/src/services/freemail.py index 9cd75ca..992d6aa 100644 --- a/src/services/freemail.py +++ b/src/services/freemail.py @@ -254,14 +254,6 @@ def get_verification_code( if "openai" not in content.lower(): continue - 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 - code = self._extract_otp_from_text(content, pattern) if code: if not self._accept_verification_code(email, code, message_marker): @@ -284,6 +276,14 @@ def get_verification_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 + except Exception as e: logger.debug(f"检查 Freemail 邮件时出错: {e}") diff --git a/tests/test_register_otp_integration.py b/tests/test_register_otp_integration.py index e291c40..d7b18f0 100644 --- a/tests/test_register_otp_integration.py +++ b/tests/test_register_otp_integration.py @@ -61,8 +61,9 @@ def test_registration_engine_resend_flow_propagates_new_otp_timestamp_and_valida engine.email_service = FakeEmailService() engine.session = FakeSession() engine._otp_sent_at = None + engine.phase_history = [] - issued_timestamps = iter([1000.0, 1005.0]) + 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 From 06f2c9a7db0a8de82ddf1113f93791c56057ed6b Mon Sep 17 00:00:00 2001 From: zhoukailian Date: Fri, 27 Mar 2026 09:20:36 +0800 Subject: [PATCH 6/6] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=20moe=5Fmail=20?= =?UTF-8?q?=E5=A4=9A=E5=B0=81=E9=AA=8C=E8=AF=81=E7=A0=81=E7=9A=84=E8=B7=A8?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=9B=9E=E5=BD=92=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_mail_code_reuse_guard.py | 111 ++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/tests/test_mail_code_reuse_guard.py b/tests/test_mail_code_reuse_guard.py index 9f30698..fe300af 100644 --- a/tests/test_mail_code_reuse_guard.py +++ b/tests/test_mail_code_reuse_guard.py @@ -410,3 +410,114 @@ def fake_make_request(method, endpoint, **kwargs): ) 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", + ]