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}