From 51922ef2a608b8e363fb59d91f50954c26111d42 Mon Sep 17 00:00:00 2001 From: cnlimiter Date: Sat, 28 Mar 2026 02:51:12 +0800 Subject: [PATCH] feat(email): add configurable resend limits for non-OpenAI sender emails --- src/config/settings.py | 16 + src/core/register.py | 71 +- src/services/base.py | 48 +- src/services/cloud_mail.py | 17 +- src/services/duck_mail.py | 20 +- src/services/freemail.py | 19 +- src/services/imap_mail.py | 37 +- src/services/moe_mail.py | 13 +- src/services/outlook/email_parser.py | 42 +- src/services/outlook/health_checker.py | 145 ++-- src/services/outlook/service.py | 22 +- src/services/outlook_legacy_mail.py | 748 ------------------ src/services/temp_mail.py | 27 +- src/services/tempmail.py | 17 +- src/web/routes/registration.py | 6 + src/web/routes/settings.py | 22 +- static/js/accounts.js | 19 + static/js/email_services.js | 26 +- static/js/settings.js | 9 +- templates/accounts.html | 6 + templates/email_services.html | 88 ++- templates/settings.html | 6 + .../check_otp_timing.py | 0 probe_tempmail.py => tests/probe_tempmail.py | 0 tests/test_mail_openai_detection.py | 45 ++ .../test_outlook_service_config_and_health.py | 127 +++ tests/test_registration_otp_phase.py | 147 +++- ...ntime_functionality_report_1774308869.json | 292 ------- .../runtime_recovery_report_1774308869.json | 38 - .../runtime_recovery_state_1774308869.json | 5 - 30 files changed, 815 insertions(+), 1263 deletions(-) delete mode 100644 src/services/outlook_legacy_mail.py rename check_otp_timing.py => tests/check_otp_timing.py (100%) rename probe_tempmail.py => tests/probe_tempmail.py (100%) create mode 100644 tests/test_mail_openai_detection.py create mode 100644 tests/test_outlook_service_config_and_health.py delete mode 100644 tests_runtime/runtime_functionality_report_1774308869.json delete mode 100644 tests_runtime/runtime_recovery_report_1774308869.json delete mode 100644 tests_runtime/runtime_recovery_state_1774308869.json diff --git a/src/config/settings.py b/src/config/settings.py index 290dcfb..9d33b64 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -364,6 +364,12 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = { category=SettingCategory.EMAIL, description="收件箱未找到验证码时,最多重新发送验证码的次数" ), + "email_code_non_openai_sender_resend_max_retries": SettingDefinition( + db_key="email_code.non_openai_sender_resend_max_retries", + default_value=1, + category=SettingCategory.EMAIL, + description="检测到非 OpenAI 发件人干扰时,最多重新发送验证码的次数" + ), # Outlook 配置 "outlook_provider_priority": SettingDefinition( @@ -390,6 +396,12 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = { category=SettingCategory.EMAIL, description="Outlook OAuth 默认 Client ID" ), + "outlook_require_recipient_match": SettingDefinition( + db_key="outlook.require_recipient_match", + default_value=True, + category=SettingCategory.EMAIL, + description="Outlook 验证码识别时是否校验收件人匹配" + ), } # 属性名到数据库键名的映射(用于向后兼容) @@ -416,9 +428,11 @@ SETTING_TYPES: Dict[str, Type] = { "email_code_timeout": int, "email_code_poll_interval": int, "email_code_resend_max_retries": int, + "email_code_non_openai_sender_resend_max_retries": int, "outlook_provider_priority": list, "outlook_health_failure_threshold": int, "outlook_health_disable_duration": int, + "outlook_require_recipient_match": bool, } # 需要作为 SecretStr 处理的字段 @@ -717,12 +731,14 @@ class Settings(BaseModel): email_code_timeout: int = 120 email_code_poll_interval: int = 3 email_code_resend_max_retries: int = 2 + email_code_non_openai_sender_resend_max_retries: int = 1 # Outlook 配置 outlook_provider_priority: List[str] = ["imap_old", "imap_new", "graph_api"] outlook_health_failure_threshold: int = 5 outlook_health_disable_duration: int = 60 outlook_default_client_id: str = "24d9a0ed-8787-4584-883c-2fd79308940a" + outlook_require_recipient_match: bool = True # 全局配置实例 diff --git a/src/core/register.py b/src/core/register.py index cc27f9b..62b2f76 100644 --- a/src/core/register.py +++ b/src/core/register.py @@ -19,7 +19,11 @@ from curl_cffi import requests as cffi_requests from .openai.oauth import OAuthManager, OAuthStart from .http_client import OpenAIHTTPClient, HTTPClientError from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType -from ..services.base import EmailProviderBackoffState +from ..services.base import ( + EmailProviderBackoffState, + OTP_NO_OPENAI_SENDER_ERROR, + OTPNoOpenAISenderEmailServiceError, +) from ..database import crud from ..database.session import get_db from ..config.constants import ( @@ -712,6 +716,21 @@ class RegistrationEngine: return None, phase_result except Exception as e: + if isinstance(e, OTPNoOpenAISenderEmailServiceError): + self._log(str(e), "warning") + phase_result = self._record_phase_result( + PhaseResult( + phase=PHASE_OTP_SECONDARY, + success=False, + error_message=str(e), + error_code=getattr(e, "error_code", ""), + retryable=True, + next_action="resend_otp", + metadata={"otp_sent_at": context.otp_sent_at}, + ) + ) + return None, phase_result + self._log(f"获取验证码失败: {e}", "error") phase_result = self._record_phase_result( PhaseResult( @@ -1541,21 +1560,55 @@ class RegistrationEngine: self._log("10. 等待验证码...") self._emit_status("otp_secondary", "等待验证码邮件", step_index=10) otp_phase_started_at = time.time() - _resend_max = get_settings().email_code_resend_max_retries + settings = get_settings() + timeout_resend_max = settings.email_code_resend_max_retries + non_openai_sender_resend_max = settings.email_code_non_openai_sender_resend_max_retries + timeout_resend_used = 0 + non_openai_sender_resend_used = 0 code, otp_phase = None, None - for _resend_attempt in range(_resend_max + 1): - if _resend_attempt > 0: - self._log(f"10. 收件箱未找到验证码,第 {_resend_attempt} 次重新发送验证码...") - self._emit_status("otp_resend", f"重新发送验证码(第 {_resend_attempt} 次)", step_index=10) - if not self._send_verification_code(): - self._log("重新发送验证码失败,跳过本次重试", "warning") - continue + while True: code, otp_phase = self._phase_otp_secondary( PhaseContext(otp_sent_at=self._otp_sent_at), started_at=otp_phase_started_at, ) if code: break + + retry_error_code = otp_phase.error_code if otp_phase else "" + retry_reason = ( + "non_openai_sender" + if retry_error_code == OTP_NO_OPENAI_SENDER_ERROR + else "timeout" + ) + + if retry_reason == "non_openai_sender": + if non_openai_sender_resend_used >= non_openai_sender_resend_max: + break + non_openai_sender_resend_used += 1 + resend_attempt = non_openai_sender_resend_used + self._log( + f"10. 检测到非 OpenAI 发件人干扰,第 {resend_attempt} 次重新发送验证码..." + ) + self._emit_status( + "otp_resend", + f"重新发送验证码(非 OpenAI 发件人,第 {resend_attempt} 次)", + step_index=10, + ) + else: + if timeout_resend_used >= timeout_resend_max: + break + timeout_resend_used += 1 + resend_attempt = timeout_resend_used + self._log(f"10. 收件箱未找到验证码,第 {resend_attempt} 次重新发送验证码...") + self._emit_status( + "otp_resend", + f"重新发送验证码(第 {resend_attempt} 次)", + step_index=10, + ) + + if not self._send_verification_code(): + self._log("重新发送验证码失败,跳过本次重试", "warning") + otp_phase_started_at = time.time() if not code: result.error_message = ( diff --git a/src/services/base.py b/src/services/base.py index f373bf2..ca58866 100644 --- a/src/services/base.py +++ b/src/services/base.py @@ -12,7 +12,7 @@ from datetime import datetime from typing import Optional, Dict, Any, List from enum import Enum -from ..config.constants import EmailServiceType, OTP_CODE_PATTERN, OTP_CODE_SEMANTIC_PATTERN +from ..config.constants import EmailServiceType, OPENAI_EMAIL_SENDERS, OTP_CODE_PATTERN, OTP_CODE_SEMANTIC_PATTERN from ..config.settings import get_settings @@ -30,6 +30,7 @@ def get_email_code_settings() -> dict: } EMAIL_PROVIDER_BACKOFF_MAX_SECONDS = 3600 OTP_TIMEOUT_ERROR_PREFIX = "OTP_TIMEOUT" +OTP_NO_OPENAI_SENDER_ERROR = "OTP_NO_OPENAI_SENDER" @dataclass(frozen=True) @@ -130,6 +131,14 @@ class OTPTimeoutEmailServiceError(EmailServiceError): self.error_code = error_code +class OTPNoOpenAISenderEmailServiceError(EmailServiceError): + """当前轮询批次未发现 OpenAI 发件人,建议立即重发验证码。""" + + def __init__(self, message: str = "当前邮件批次未发现 OpenAI 发件人", error_code: str = OTP_NO_OPENAI_SENDER_ERROR): + super().__init__(message) + self.error_code = error_code + + class EmailServiceStatus(Enum): """邮箱服务状态""" HEALTHY = "healthy" @@ -312,6 +321,42 @@ class BaseEmailService(abc.ABC): return None + def _is_openai_sender_value(self, sender: Any) -> bool: + """判断单个发件人字段是否属于 OpenAI。""" + sender_text = str(sender or "").strip().lower() + if not sender_text: + return False + + for known_sender in OPENAI_EMAIL_SENDERS: + normalized = known_sender.lower() + if normalized.startswith(("@", ".")): + if normalized in sender_text: + return True + elif normalized in sender_text: + return True + return False + + def _message_mentions_openai(self, *parts: Any) -> bool: + """判断若干文本片段中是否提及 OpenAI。""" + combined = "\n".join(str(part or "") for part in parts if part is not None).lower() + return "openai" in combined if combined else False + + def _is_openai_candidate_message(self, sender: Any = None, *content_parts: Any) -> bool: + """判断单封邮件是否可作为 OpenAI 验证码候选邮件。""" + return self._is_openai_sender_value(sender) or self._message_mentions_openai(sender, *content_parts) + + def _batch_has_openai_sender(self, items: List[Any], sender_getter) -> bool: + """判断当前批次邮件是否至少有一封来自 OpenAI 发件人。""" + found_sender_field = False + for item in items: + sender = sender_getter(item) + if sender in (None, ""): + continue + found_sender_field = True + if self._is_openai_sender_value(sender): + return True + return not found_sender_field + def _get_used_verification_codes(self, email: str) -> set: """获取邮箱对应的已使用验证码集合。""" key = str(email or "").strip().lower() @@ -470,7 +515,6 @@ class BaseEmailService(abc.ABC): 邮件信息字典,如果超时返回 None """ import time - from datetime import datetime start_time = time.time() last_email_id = None diff --git a/src/services/cloud_mail.py b/src/services/cloud_mail.py index a125e14..9e595b2 100644 --- a/src/services/cloud_mail.py +++ b/src/services/cloud_mail.py @@ -10,7 +10,7 @@ import time from datetime import datetime, timezone from typing import Any, Dict, List, Optional -from .base import BaseEmailService, EmailServiceError, EmailServiceType, RateLimitedEmailServiceError, get_email_code_settings +from .base import BaseEmailService, EmailServiceError, EmailServiceType, OTPNoOpenAISenderEmailServiceError, RateLimitedEmailServiceError, get_email_code_settings from ..config.constants import OTP_CODE_PATTERN from ..core.http_client import HTTPClient, RequestConfig @@ -256,6 +256,17 @@ class CloudMailService(BaseEmailService): time.sleep(poll_interval) continue + if mails: + sender_values = [ + mail for mail in mails + if isinstance(mail, dict) and (mail.get("sendEmail") or mail.get("sender")) + ] + if sender_values and not self._batch_has_openai_sender( + sender_values, + lambda item: item.get("sendEmail") or item.get("sender"), + ): + raise OTPNoOpenAISenderEmailServiceError() + for mail in mails: msg_timestamp = self._get_received_timestamp(mail) if otp_sent_at is not None: @@ -278,7 +289,7 @@ class CloudMailService(BaseEmailService): part for part in [sender, sender_name, subject, text_body, content] if part ).strip() - if "openai" not in search_text.lower(): + if not self._is_openai_candidate_message(sender, sender_name, subject, text_body, content): continue code = self._extract_otp_from_text(search_text, pattern) @@ -287,6 +298,8 @@ class CloudMailService(BaseEmailService): logger.info(f"从 Cloud Mail 邮箱 {email} 找到验证码: {code}") return code except Exception as e: + if isinstance(e, OTPNoOpenAISenderEmailServiceError): + raise logger.debug(f"检查 Cloud Mail 邮件时出错: {e}") time.sleep(poll_interval) diff --git a/src/services/duck_mail.py b/src/services/duck_mail.py index b8d977a..a28e853 100644 --- a/src/services/duck_mail.py +++ b/src/services/duck_mail.py @@ -12,7 +12,7 @@ from datetime import datetime, timezone from html import unescape from typing import Any, Dict, List, Optional -from .base import BaseEmailService, EmailServiceError, EmailServiceType, RateLimitedEmailServiceError, get_email_code_settings +from .base import BaseEmailService, EmailServiceError, EmailServiceType, OTPNoOpenAISenderEmailServiceError, RateLimitedEmailServiceError, get_email_code_settings from ..config.constants import OTP_CODE_PATTERN from ..core.http_client import HTTPClient, RequestConfig @@ -277,6 +277,17 @@ class DuckMailService(BaseEmailService): lambda item: item.get("createdAt") if isinstance(item, dict) else None, ) + if ordered_messages: + sender_values = [ + msg for msg in ordered_messages + if isinstance(msg, dict) and (msg.get("from") or msg.get("sender")) + ] + if sender_values and not self._batch_has_openai_sender( + sender_values, + lambda item: item.get("from") or item.get("sender"), + ): + raise OTPNoOpenAISenderEmailServiceError() + for message in ordered_messages: message_id = str(message.get("id") or "").strip() if not message_id or message_id in seen_message_ids: @@ -295,7 +306,10 @@ class DuckMailService(BaseEmailService): ) content = self._message_search_text(message, detail) - if "openai" not in content.lower(): + if not self._is_openai_candidate_message( + message.get("from") or message.get("sender"), + content, + ): continue match = re.search(pattern, content) @@ -306,6 +320,8 @@ class DuckMailService(BaseEmailService): self.update_status(True) return code except Exception as e: + if isinstance(e, OTPNoOpenAISenderEmailServiceError): + raise logger.debug(f"DuckMail 轮询验证码失败: {e}") time.sleep(poll_interval) diff --git a/src/services/freemail.py b/src/services/freemail.py index 84c9fbc..5d7e204 100644 --- a/src/services/freemail.py +++ b/src/services/freemail.py @@ -6,11 +6,9 @@ Freemail 邮箱服务实现 import re import time import logging -import random -import string from typing import Optional, Dict, Any, List -from .base import BaseEmailService, EmailServiceError, EmailServiceType, RateLimitedEmailServiceError, get_email_code_settings +from .base import BaseEmailService, EmailServiceError, EmailServiceType, OTPNoOpenAISenderEmailServiceError, RateLimitedEmailServiceError, get_email_code_settings from ..core.http_client import HTTPClient, RequestConfig from ..config.constants import OTP_CODE_PATTERN @@ -232,6 +230,17 @@ class FreemailService(BaseEmailService): ) if isinstance(item, dict) else None, ) + if ordered_mails: + sender_values = [ + mail for mail in ordered_mails + if isinstance(mail, dict) and mail.get("sender") + ] + if sender_values and not self._batch_has_openai_sender( + sender_values, + lambda item: item.get("sender"), + ): + raise OTPNoOpenAISenderEmailServiceError() + for mail in ordered_mails: mail_id = mail.get("id") if not mail_id or mail_id in seen_mail_ids: @@ -252,7 +261,7 @@ class FreemailService(BaseEmailService): content = f"{sender}\n{subject}\n{preview}" - if "openai" not in content.lower(): + if not self._is_openai_candidate_message(sender, subject, preview): continue code = self._extract_otp_from_text(content, pattern) @@ -286,6 +295,8 @@ class FreemailService(BaseEmailService): return v_code except Exception as e: + if isinstance(e, OTPNoOpenAISenderEmailServiceError): + raise logger.debug(f"检查 Freemail 邮件时出错: {e}") time.sleep(poll_interval) diff --git a/src/services/imap_mail.py b/src/services/imap_mail.py index 13efdf6..c65b3da 100644 --- a/src/services/imap_mail.py +++ b/src/services/imap_mail.py @@ -5,17 +5,16 @@ IMAP 邮箱服务 """ import imaplib -import email +import email as py_email import re import time import logging from email.header import decode_header from typing import Any, Dict, Optional -from .base import BaseEmailService, EmailServiceError, get_email_code_settings +from .base import BaseEmailService, EmailServiceError, OTPNoOpenAISenderEmailServiceError, get_email_code_settings from ..config.constants import ( EmailServiceType, - OPENAI_EMAIL_SENDERS, OTP_CODE_SEMANTIC_PATTERN, OTP_CODE_PATTERN, ) @@ -85,15 +84,7 @@ class ImapMailService(BaseEmailService): def _is_openai_sender(self, from_addr: str) -> bool: """判断发件人是否为 OpenAI""" - from_lower = from_addr.lower() - for sender in OPENAI_EMAIL_SENDERS: - if sender.startswith("@") or sender.startswith("."): - if sender in from_lower: - return True - else: - if sender in from_lower: - return True - return False + return self._is_openai_sender_value(from_addr) def _extract_otp(self, text: str) -> Optional[str]: """从文本中提取 6 位验证码,优先语义匹配,回退简单匹配""" @@ -126,7 +117,7 @@ class ImapMailService(BaseEmailService): poll_interval = get_email_code_settings()["poll_interval"] start_time = time.time() seen_ids: set = set() - mail = None + mail: Optional[imaplib.IMAP4] = None try: mail = self._connect() @@ -141,24 +132,32 @@ class ImapMailService(BaseEmailService): continue msg_ids = data[0].split() + seen_any_message = False + found_openai_sender = False for msg_id in reversed(msg_ids): # 最新的优先 id_str = msg_id.decode() if id_str in seen_ids: continue seen_ids.add(id_str) + seen_any_message = True # 获取邮件 status, msg_data = mail.fetch(msg_id, "(RFC822)") if status != "OK" or not msg_data: continue - raw = msg_data[0][1] - msg = email.message_from_bytes(raw) + first_part = msg_data[0] + if not isinstance(first_part, tuple) or len(first_part) < 2 or first_part[1] is None: + continue + + raw = first_part[1] + msg = py_email.message_from_bytes(raw) # 检查发件人 from_addr = self._decode_str(msg.get("From", "")) if not self._is_openai_sender(from_addr): continue + found_openai_sender = True # 提取验证码 body = self._get_text_body(msg) @@ -170,17 +169,23 @@ class ImapMailService(BaseEmailService): logger.info(f"IMAP 获取验证码成功: {code}") return code + if seen_any_message and not found_openai_sender: + raise OTPNoOpenAISenderEmailServiceError() + except imaplib.IMAP4.error as e: logger.debug(f"IMAP 搜索邮件失败: {e}") # 尝试重新连接 try: - mail.select("INBOX") + if mail is not None: + mail.select("INBOX") except Exception: pass time.sleep(poll_interval) except Exception as e: + if isinstance(e, OTPNoOpenAISenderEmailServiceError): + raise logger.warning(f"IMAP 连接/轮询失败: {e}") self.update_status(False, str(e)) finally: diff --git a/src/services/moe_mail.py b/src/services/moe_mail.py index 8368c5b..e4eaa97 100644 --- a/src/services/moe_mail.py +++ b/src/services/moe_mail.py @@ -10,7 +10,7 @@ import logging from typing import Optional, Dict, Any, List from urllib.parse import urljoin -from .base import BaseEmailService, EmailServiceError, EmailServiceType, RateLimitedEmailServiceError, get_email_code_settings +from .base import BaseEmailService, EmailServiceError, EmailServiceType, OTPNoOpenAISenderEmailServiceError, RateLimitedEmailServiceError, get_email_code_settings from ..core.http_client import HTTPClient, RequestConfig from ..config.constants import OTP_CODE_PATTERN @@ -327,6 +327,13 @@ class MeoMailEmailService(BaseEmailService): ) if isinstance(item, dict) else None, ) + if ordered_messages: + if not self._batch_has_openai_sender( + ordered_messages, + lambda item: item.get("from_address") if isinstance(item, dict) else None, + ): + raise OTPNoOpenAISenderEmailServiceError() + for message in ordered_messages: message_id = message.get("id") if not message_id or message_id in seen_message_ids: @@ -353,7 +360,7 @@ class MeoMailEmailService(BaseEmailService): content = f"{sender} {subject} {message_content}" # 检查是否是 OpenAI 邮件 - if "openai" not in sender and "openai" not in content.lower(): + if not self._is_openai_candidate_message(sender, subject, message_content): continue # 提取验证码 过滤掉邮箱 @@ -368,6 +375,8 @@ class MeoMailEmailService(BaseEmailService): return code except Exception as e: + if isinstance(e, OTPNoOpenAISenderEmailServiceError): + raise logger.debug(f"检查邮件时出错: {e}") # 等待一段时间再检查 diff --git a/src/services/outlook/email_parser.py b/src/services/outlook/email_parser.py index 84d5228..bcfcf32 100644 --- a/src/services/outlook/email_parser.py +++ b/src/services/outlook/email_parser.py @@ -4,7 +4,7 @@ import logging import re -from typing import Optional, List, Dict, Any +from typing import Optional, List from ...config.constants import ( OTP_CODE_SIMPLE_PATTERN, @@ -33,6 +33,7 @@ class EmailParser: self, email: EmailMessage, target_email: Optional[str] = None, + require_recipient_match: bool = True, ) -> bool: """ 判断是否为 OpenAI 验证邮件 @@ -60,10 +61,32 @@ class EmailParser: logger.debug(f"邮件未包含验证关键词: {subject[:50]}") return False - # 3. 收件人检查已移除:别名邮件的 IMAP 头中收件人可能不匹配,只靠发件人+关键词判断 + # 3. 可选收件人检查:默认启用,别名或转发场景可通过配置关闭 + if require_recipient_match and target_email: + if not self._recipient_matches_target(email, target_email): + logger.debug("邮件收件人不匹配目标邮箱") + return False + logger.debug(f"识别为 OpenAI 验证邮件: {subject[:50]}") return True + def _recipient_matches_target(self, email: EmailMessage, target_email: str) -> bool: + target = (target_email or "").strip().lower() + if not target: + return True + + for recipient in email.recipients or []: + normalized = str(recipient or "").strip().lower() + if not normalized: + continue + if target == normalized: + return True + # 兼容 "Name " 形式 + if f"<{target}>" in normalized: + return True + + return False + def extract_verification_code( self, email: EmailMessage, @@ -123,11 +146,20 @@ class EmailParser: return match.group(1) return None + def has_openai_sender(self, emails: List[EmailMessage]) -> bool: + """判断邮件批次中是否至少存在一封 OpenAI 发件人邮件。""" + for email in emails: + sender = (email.sender or "").lower() + if any(pattern in sender for pattern in OPENAI_EMAIL_SENDERS): + return True + return False + def find_verification_code_in_emails( self, emails: List[EmailMessage], target_email: Optional[str] = None, min_timestamp: int = 0, + require_recipient_match: bool = True, used_codes: Optional[set] = None, ) -> Optional[str]: """ @@ -152,7 +184,11 @@ class EmailParser: continue # 检查是否是 OpenAI 验证邮件 - if not self.is_openai_verification_email(email, target_email): + if not self.is_openai_verification_email( + email, + target_email, + require_recipient_match=require_recipient_match, + ): continue # 提取验证码 diff --git a/src/services/outlook/health_checker.py b/src/services/outlook/health_checker.py index c68ed4e..0d11646 100644 --- a/src/services/outlook/health_checker.py +++ b/src/services/outlook/health_checker.py @@ -4,12 +4,10 @@ import logging import threading -import time -from datetime import datetime, timedelta -from typing import Dict, List, Optional, Any +from datetime import datetime +from typing import Dict, List, Optional, Any, Tuple from .base import ProviderType, ProviderHealth, ProviderStatus -from .providers.base import OutlookProvider logger = logging.getLogger(__name__) @@ -39,45 +37,52 @@ class HealthChecker: self.disable_duration = disable_duration self.recovery_check_interval = recovery_check_interval - # 提供者健康状态: ProviderType -> ProviderHealth - self._health_status: Dict[ProviderType, ProviderHealth] = {} + # 提供者健康状态: (account_email, provider_type) -> ProviderHealth + self._health_status: Dict[Tuple[str, ProviderType], ProviderHealth] = {} self._lock = threading.Lock() - # 初始化所有提供者的健康状态 - for provider_type in ProviderType: - self._health_status[provider_type] = ProviderHealth( - provider_type=provider_type - ) + @staticmethod + def _normalize_account_email(account_email: Optional[str]) -> str: + return (account_email or "__global__").strip().lower() - def get_health(self, provider_type: ProviderType) -> ProviderHealth: + def _state_key(self, provider_type: ProviderType, account_email: Optional[str]) -> Tuple[str, ProviderType]: + return (self._normalize_account_email(account_email), provider_type) + + def _ensure_health(self, provider_type: ProviderType, account_email: Optional[str]) -> ProviderHealth: + key = self._state_key(provider_type, account_email) + health = self._health_status.get(key) + if health is None: + health = ProviderHealth(provider_type=provider_type) + self._health_status[key] = health + return health + + def get_health(self, provider_type: ProviderType, account_email: Optional[str] = None) -> ProviderHealth: """获取提供者的健康状态""" with self._lock: - return self._health_status.get(provider_type, ProviderHealth(provider_type=provider_type)) + return self._ensure_health(provider_type, account_email) - def record_success(self, provider_type: ProviderType): + def record_success(self, provider_type: ProviderType, account_email: Optional[str] = None): """记录成功操作""" with self._lock: - health = self._health_status.get(provider_type) - if health: - health.record_success() - logger.debug(f"{provider_type.value} 记录成功") + health = self._ensure_health(provider_type, account_email) + health.record_success() + logger.debug(f"{provider_type.value} 记录成功 ({self._normalize_account_email(account_email)})") - def record_failure(self, provider_type: ProviderType, error: str): + def record_failure(self, provider_type: ProviderType, error: str, account_email: Optional[str] = None): """记录失败操作""" with self._lock: - health = self._health_status.get(provider_type) - if health: - health.record_failure(error) + health = self._ensure_health(provider_type, account_email) + health.record_failure(error) - # 检查是否需要禁用 - if health.should_disable(self.failure_threshold): - health.disable(self.disable_duration) - logger.warning( - f"{provider_type.value} 已禁用 {self.disable_duration} 秒," - f"原因: {error}" - ) + # 检查是否需要禁用 + if health.should_disable(self.failure_threshold): + health.disable(self.disable_duration) + logger.warning( + f"{provider_type.value} 已禁用 {self.disable_duration} 秒 " + f"({self._normalize_account_email(account_email)}),原因: {error}" + ) - def is_available(self, provider_type: ProviderType) -> bool: + def is_available(self, provider_type: ProviderType, account_email: Optional[str] = None) -> bool: """ 检查提供者是否可用 @@ -87,13 +92,14 @@ class HealthChecker: Returns: 是否可用 """ - health = self.get_health(provider_type) + health = self.get_health(provider_type, account_email) # 检查是否被禁用 if health.is_disabled(): remaining = (health.disabled_until - datetime.now()).total_seconds() logger.debug( - f"{provider_type.value} 已被禁用,剩余 {int(remaining)} 秒" + f"{provider_type.value} 已被禁用,剩余 {int(remaining)} 秒 " + f"({self._normalize_account_email(account_email)})" ) return False @@ -102,6 +108,7 @@ class HealthChecker: def get_available_providers( self, priority_order: Optional[List[ProviderType]] = None, + account_email: Optional[str] = None, ) -> List[ProviderType]: """ 获取可用的提供者列表 @@ -121,7 +128,7 @@ class HealthChecker: available = [] for provider_type in priority_order: - if self.is_available(provider_type): + if self.is_available(provider_type, account_email): available.append(provider_type) return available @@ -129,6 +136,7 @@ class HealthChecker: def get_next_available_provider( self, priority_order: Optional[List[ProviderType]] = None, + account_email: Optional[str] = None, ) -> Optional[ProviderType]: """ 获取下一个可用的提供者 @@ -139,10 +147,10 @@ class HealthChecker: Returns: 可用的提供者类型,如果没有返回 None """ - available = self.get_available_providers(priority_order) + available = self.get_available_providers(priority_order, account_email) return available[0] if available else None - def force_disable(self, provider_type: ProviderType, duration: Optional[int] = None): + def force_disable(self, provider_type: ProviderType, duration: Optional[int] = None, account_email: Optional[str] = None): """ 强制禁用提供者 @@ -151,12 +159,11 @@ class HealthChecker: duration: 禁用时长(秒),默认使用配置值 """ with self._lock: - health = self._health_status.get(provider_type) - if health: - health.disable(duration or self.disable_duration) - logger.warning(f"{provider_type.value} 已强制禁用") + health = self._ensure_health(provider_type, account_email) + health.disable(duration or self.disable_duration) + logger.warning(f"{provider_type.value} 已强制禁用 ({self._normalize_account_email(account_email)})") - def force_enable(self, provider_type: ProviderType): + def force_enable(self, provider_type: ProviderType, account_email: Optional[str] = None): """ 强制启用提供者 @@ -164,12 +171,11 @@ class HealthChecker: provider_type: 提供者类型 """ with self._lock: - health = self._health_status.get(provider_type) - if health: - health.enable() - logger.info(f"{provider_type.value} 已启用") + health = self._ensure_health(provider_type, account_email) + health.enable() + logger.info(f"{provider_type.value} 已启用 ({self._normalize_account_email(account_email)})") - def get_all_health_status(self) -> Dict[str, Any]: + def get_all_health_status(self, account_email: Optional[str] = None) -> Dict[str, Any]: """ 获取所有提供者的健康状态 @@ -177,10 +183,16 @@ class HealthChecker: 健康状态字典 """ with self._lock: - return { - provider_type.value: health.to_dict() - for provider_type, health in self._health_status.items() - } + normalized_account = self._normalize_account_email(account_email) if account_email is not None else None + grouped: Dict[str, Dict[str, Any]] = {} + for (acc_email, provider_type), health in self._health_status.items(): + if normalized_account is not None and acc_email != normalized_account: + continue + grouped.setdefault(acc_email, {})[provider_type.value] = health.to_dict() + + if normalized_account is not None: + return grouped.get(normalized_account, {}) + return grouped def check_and_recover(self): """ @@ -189,20 +201,17 @@ class HealthChecker: 如果禁用时间已过,自动恢复提供者 """ with self._lock: - for provider_type, health in self._health_status.items(): + for (account_email, provider_type), health in self._health_status.items(): if health.is_disabled(): # 检查是否可以恢复 if health.disabled_until and datetime.now() >= health.disabled_until: health.enable() - logger.info(f"{provider_type.value} 已自动恢复") + logger.info(f"{provider_type.value} 已自动恢复 ({account_email})") def reset_all(self): """重置所有提供者的健康状态""" with self._lock: - for provider_type in ProviderType: - self._health_status[provider_type] = ProviderHealth( - provider_type=provider_type - ) + self._health_status.clear() logger.info("已重置所有提供者的健康状态") @@ -235,14 +244,14 @@ class FailoverManager: self._current_index = 0 self._lock = threading.Lock() - def get_current_provider(self) -> Optional[ProviderType]: + def get_current_provider(self, account_email: Optional[str] = None) -> Optional[ProviderType]: """ 获取当前提供者 Returns: 当前提供者类型,如果没有可用的返回 None """ - available = self.health_checker.get_available_providers(self.priority_order) + available = self.health_checker.get_available_providers(self.priority_order, account_email) if not available: return None @@ -252,14 +261,14 @@ class FailoverManager: return available[self._current_index] return available[0] - def switch_to_next(self) -> Optional[ProviderType]: + def switch_to_next(self, account_email: Optional[str] = None) -> Optional[ProviderType]: """ 切换到下一个提供者 Returns: 下一个提供者类型,如果没有可用的返回 None """ - available = self.health_checker.get_available_providers(self.priority_order) + available = self.health_checker.get_available_providers(self.priority_order, account_email) if not available: return None @@ -269,22 +278,22 @@ class FailoverManager: logger.info(f"切换到提供者: {next_provider.value}") return next_provider - def on_provider_success(self, provider_type: ProviderType): + def on_provider_success(self, provider_type: ProviderType, account_email: Optional[str] = None): """ 提供者成功时调用 Args: provider_type: 提供者类型 """ - self.health_checker.record_success(provider_type) + self.health_checker.record_success(provider_type, account_email) # 重置索引到成功的提供者 with self._lock: - available = self.health_checker.get_available_providers(self.priority_order) + available = self.health_checker.get_available_providers(self.priority_order, account_email) if provider_type in available: self._current_index = available.index(provider_type) - def on_provider_failure(self, provider_type: ProviderType, error: str): + def on_provider_failure(self, provider_type: ProviderType, error: str, account_email: Optional[str] = None): """ 提供者失败时调用 @@ -292,21 +301,21 @@ class FailoverManager: provider_type: 提供者类型 error: 错误信息 """ - self.health_checker.record_failure(provider_type, error) + self.health_checker.record_failure(provider_type, error, account_email) - def get_status(self) -> Dict[str, Any]: + def get_status(self, account_email: Optional[str] = None) -> Dict[str, Any]: """ 获取故障切换状态 Returns: 状态字典 """ - current = self.get_current_provider() + current = self.get_current_provider(account_email) return { "current_provider": current.value if current else None, "priority_order": [p.value for p in self.priority_order], "available_providers": [ - p.value for p in self.health_checker.get_available_providers(self.priority_order) + p.value for p in self.health_checker.get_available_providers(self.priority_order, account_email) ], - "health_status": self.health_checker.get_all_health_status(), + "health_status": self.health_checker.get_all_health_status(account_email), } diff --git a/src/services/outlook/service.py b/src/services/outlook/service.py index f034f6d..f2141e4 100644 --- a/src/services/outlook/service.py +++ b/src/services/outlook/service.py @@ -8,11 +8,12 @@ import threading import time from typing import Optional, Dict, Any, List -from ..base import BaseEmailService, EmailServiceError, EmailServiceStatus, EmailServiceType, get_email_code_settings +from ..base import BaseEmailService, EmailServiceError, OTPNoOpenAISenderEmailServiceError, get_email_code_settings from ...config.constants import EmailServiceType as ServiceType +from ...config.settings import get_settings from .account import OutlookAccount from .base import ProviderType, EmailMessage -from .email_parser import EmailParser, get_email_parser +from .email_parser import get_email_parser from .health_checker import HealthChecker, FailoverManager from .providers.base import OutlookProvider, ProviderConfig from .providers.imap_old import IMAPOldProvider @@ -215,7 +216,7 @@ class OutlookService(BaseEmailService): # 按优先级尝试各提供者 for provider_type in priority: # 检查提供者是否可用 - if not self.health_checker.is_available(provider_type): + if not self.health_checker.is_available(provider_type, account.email): logger.debug( f"[{account.email}] {provider_type.value} 不可用,跳过" ) @@ -230,7 +231,7 @@ class OutlookService(BaseEmailService): if emails: # 成功获取邮件 - self.health_checker.record_success(provider_type) + self.health_checker.record_success(provider_type, account.email) logger.debug( f"[{account.email}] {provider_type.value} 获取到 {len(emails)} 封邮件" ) @@ -239,7 +240,7 @@ class OutlookService(BaseEmailService): except Exception as e: error_msg = str(e) errors.append(f"{provider_type.value}: {error_msg}") - self.health_checker.record_failure(provider_type, error_msg) + self.health_checker.record_failure(provider_type, error_msg, account.email) logger.warning( f"[{account.email}] {provider_type.value} 获取邮件失败: {e}" ) @@ -322,6 +323,7 @@ class OutlookService(BaseEmailService): f"[{email}] 开始获取验证码,超时 {actual_timeout}s," f"提供者优先级: {[p.value for p in self.provider_priority]}" ) + require_recipient_match = bool(self.config.get("require_recipient_match", True)) # 初始化验证码去重集合 if email not in self._used_codes: @@ -353,11 +355,19 @@ class OutlookService(BaseEmailService): f"[{email}] 第 {poll_count} 次轮询获取到 {len(emails)} 封邮件" ) + # 当前批次全部不是 OpenAI 发件人时,立即结束本轮等待,交给上层触发重发 + if not self.email_parser.has_openai_sender(emails): + logger.info( + f"[{email}] 当前邮件批次未发现 OpenAI 发件人,提前结束等待并触发重发" + ) + raise OTPNoOpenAISenderEmailServiceError() + # 从邮件中查找验证码 code = self.email_parser.find_verification_code_in_emails( emails, target_email=email, min_timestamp=min_timestamp, + require_recipient_match=require_recipient_match, used_codes=used_codes, ) @@ -372,6 +382,8 @@ class OutlookService(BaseEmailService): return code except Exception as e: + if isinstance(e, OTPNoOpenAISenderEmailServiceError): + raise logger.warning(f"[{email}] 检查出错: {e}") # 等待下次轮询 diff --git a/src/services/outlook_legacy_mail.py b/src/services/outlook_legacy_mail.py deleted file mode 100644 index 4481585..0000000 --- a/src/services/outlook_legacy_mail.py +++ /dev/null @@ -1,748 +0,0 @@ -""" -Outlook 邮箱服务实现 -支持 IMAP 协议,XOAUTH2 和密码认证 -""" - -import imaplib -import email -import re -import time -import threading -import json -import urllib.parse -import urllib.request -import base64 -import hashlib -import secrets -import logging -from typing import Optional, Dict, Any, List -from email.header import decode_header -from email.utils import parsedate_to_datetime -from urllib.error import HTTPError - -from .base import BaseEmailService, EmailServiceError, EmailServiceType, get_email_code_settings -from ..config.constants import ( - OTP_CODE_PATTERN, - OTP_CODE_SIMPLE_PATTERN, - OTP_CODE_SEMANTIC_PATTERN, - OPENAI_EMAIL_SENDERS, - OPENAI_VERIFICATION_KEYWORDS, -) - - -logger = logging.getLogger(__name__) - - -class OutlookAccount: - """Outlook 账户信息""" - - def __init__( - self, - email: str, - password: str, - client_id: str = "", - refresh_token: str = "" - ): - self.email = email - self.password = password - self.client_id = client_id - self.refresh_token = refresh_token - - @classmethod - def from_config(cls, config: Dict[str, Any]) -> "OutlookAccount": - """从配置创建账户""" - return cls( - email=config.get("email", ""), - password=config.get("password", ""), - client_id=config.get("client_id", ""), - refresh_token=config.get("refresh_token", "") - ) - - def has_oauth(self) -> bool: - """是否支持 OAuth2""" - return bool(self.client_id and self.refresh_token) - - def validate(self) -> bool: - """验证账户信息是否有效""" - return bool(self.email and self.password) or self.has_oauth() - - -class OutlookIMAPClient: - """ - Outlook IMAP 客户端 - 支持 XOAUTH2 和密码认证 - """ - - # Microsoft OAuth2 Token 缓存 - _token_cache: Dict[str, tuple] = {} - _cache_lock = threading.Lock() - - def __init__( - self, - account: OutlookAccount, - host: str = "outlook.office365.com", - port: int = 993, - timeout: int = 20 - ): - self.account = account - self.host = host - self.port = port - self.timeout = timeout - self._conn: Optional[imaplib.IMAP4_SSL] = None - - @staticmethod - def refresh_ms_token(account: OutlookAccount, timeout: int = 15) -> str: - """刷新 Microsoft access token""" - if not account.client_id or not account.refresh_token: - raise RuntimeError("缺少 client_id 或 refresh_token") - - key = account.email.lower() - with OutlookIMAPClient._cache_lock: - cached = OutlookIMAPClient._token_cache.get(key) - if cached and time.time() < cached[1]: - return cached[0] - - body = urllib.parse.urlencode({ - "client_id": account.client_id, - "refresh_token": account.refresh_token, - "grant_type": "refresh_token", - "redirect_uri": "https://login.live.com/oauth20_desktop.srf", - }).encode() - - req = urllib.request.Request( - "https://login.live.com/oauth20_token.srf", - data=body, - headers={"Content-Type": "application/x-www-form-urlencoded"} - ) - - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - data = json.loads(resp.read()) - except HTTPError as e: - raise RuntimeError(f"MS OAuth 刷新失败: {e.code}") from e - - token = data.get("access_token") - if not token: - raise RuntimeError("MS OAuth 响应无 access_token") - - ttl = int(data.get("expires_in", 3600)) - with OutlookIMAPClient._cache_lock: - OutlookIMAPClient._token_cache[key] = (token, time.time() + ttl - 120) - - return token - - @staticmethod - def _build_xoauth2(email_addr: str, token: str) -> bytes: - """构建 XOAUTH2 认证字符串""" - return f"user={email_addr}\x01auth=Bearer {token}\x01\x01".encode() - - def connect(self): - """连接到 IMAP 服务器""" - self._conn = imaplib.IMAP4_SSL(self.host, self.port, timeout=self.timeout) - - # 优先使用 XOAUTH2 认证 - if self.account.has_oauth(): - try: - token = self.refresh_ms_token(self.account) - self._conn.authenticate( - "XOAUTH2", - lambda _: self._build_xoauth2(self.account.email, token) - ) - logger.debug(f"使用 XOAUTH2 认证连接: {self.account.email}") - return - except Exception as e: - logger.warning(f"XOAUTH2 认证失败,回退密码认证: {e}") - - # 回退到密码认证 - self._conn.login(self.account.email, self.account.password) - logger.debug(f"使用密码认证连接: {self.account.email}") - - def _ensure_connection(self): - """确保连接有效""" - if self._conn: - try: - self._conn.noop() - return - except Exception: - self.close() - - self.connect() - - def get_recent_emails( - self, - count: int = 20, - only_unseen: bool = True, - timeout: int = 30 - ) -> List[Dict[str, Any]]: - """ - 获取最近的邮件 - - Args: - count: 获取的邮件数量 - only_unseen: 是否只获取未读邮件 - timeout: 超时时间 - - Returns: - 邮件列表 - """ - self._ensure_connection() - - flag = "UNSEEN" if only_unseen else "ALL" - self._conn.select("INBOX", readonly=True) - - _, data = self._conn.search(None, flag) - if not data or not data[0]: - return [] - - # 获取最新的邮件 - ids = data[0].split()[-count:] - result = [] - - for mid in reversed(ids): - try: - _, payload = self._conn.fetch(mid, "(RFC822)") - if not payload: - continue - - raw = b"" - for part in payload: - if isinstance(part, tuple) and len(part) > 1: - raw = part[1] - break - - if raw: - result.append(self._parse_email(raw)) - except Exception as e: - logger.warning(f"解析邮件失败 (ID: {mid}): {e}") - - return result - - @staticmethod - def _parse_email(raw: bytes) -> Dict[str, Any]: - """解析邮件内容""" - # 移除可能的 BOM - if raw.startswith(b"\xef\xbb\xbf"): - raw = raw[3:] - - msg = email.message_from_bytes(raw) - - # 解析邮件头 - subject = OutlookIMAPClient._decode_header(msg.get("Subject", "")) - sender = OutlookIMAPClient._decode_header(msg.get("From", "")) - date_str = OutlookIMAPClient._decode_header(msg.get("Date", "")) - to = OutlookIMAPClient._decode_header(msg.get("To", "")) - delivered_to = OutlookIMAPClient._decode_header(msg.get("Delivered-To", "")) - x_original_to = OutlookIMAPClient._decode_header(msg.get("X-Original-To", "")) - - # 提取邮件正文 - body = OutlookIMAPClient._extract_body(msg) - - # 解析日期 - date_timestamp = 0 - try: - if date_str: - dt = parsedate_to_datetime(date_str) - date_timestamp = int(dt.timestamp()) - except Exception: - pass - - return { - "subject": subject, - "from": sender, - "date": date_str, - "date_timestamp": date_timestamp, - "to": to, - "delivered_to": delivered_to, - "x_original_to": x_original_to, - "body": body, - "raw": raw.hex()[:100] # 存储原始数据的部分哈希用于调试 - } - - @staticmethod - def _decode_header(header: str) -> str: - """解码邮件头""" - if not header: - return "" - - parts = [] - for chunk, encoding in decode_header(header): - if isinstance(chunk, bytes): - try: - decoded = chunk.decode(encoding or "utf-8", errors="replace") - parts.append(decoded) - except Exception: - parts.append(chunk.decode("utf-8", errors="replace")) - else: - parts.append(chunk) - - return "".join(parts).strip() - - @staticmethod - def _extract_body(msg) -> str: - """提取邮件正文""" - import html as html_module - - texts = [] - parts = msg.walk() if msg.is_multipart() else [msg] - - for part in parts: - content_type = part.get_content_type() - if content_type not in ("text/plain", "text/html"): - continue - - payload = part.get_payload(decode=True) - if not payload: - continue - - charset = part.get_content_charset() or "utf-8" - try: - text = payload.decode(charset, errors="replace") - except LookupError: - text = payload.decode("utf-8", errors="replace") - - # 如果是 HTML,移除标签 - if "]+>", " ", text) - - texts.append(text) - - # 合并并清理文本 - combined = " ".join(texts) - combined = html_module.unescape(combined) - combined = re.sub(r"\s+", " ", combined).strip() - - return combined - - def close(self): - """关闭连接""" - if self._conn: - try: - self._conn.close() - except Exception: - pass - try: - self._conn.logout() - except Exception: - pass - self._conn = None - - def __enter__(self): - self.connect() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() - - -class OutlookService(BaseEmailService): - """ - Outlook 邮箱服务 - 支持多个 Outlook 账户的轮询和验证码获取 - """ - - def __init__(self, config: Dict[str, Any] = None, name: str = None): - """ - 初始化 Outlook 服务 - - Args: - config: 配置字典,支持以下键: - - accounts: Outlook 账户列表,每个账户包含: - - email: 邮箱地址 - - password: 密码 - - client_id: OAuth2 client_id (可选) - - refresh_token: OAuth2 refresh_token (可选) - - imap_host: IMAP 服务器 (默认: outlook.office365.com) - - imap_port: IMAP 端口 (默认: 993) - - timeout: 超时时间 (默认: 30) - - max_retries: 最大重试次数 (默认: 3) - name: 服务名称 - """ - super().__init__(EmailServiceType.OUTLOOK, name) - - # 默认配置 - default_config = { - "accounts": [], - "imap_host": "outlook.office365.com", - "imap_port": 993, - "timeout": 30, - "max_retries": 3, - "proxy_url": None, - } - - self.config = {**default_config, **(config or {})} - - # 解析账户 - self.accounts: List[OutlookAccount] = [] - self._current_account_index = 0 - self._account_locks: Dict[str, threading.Lock] = {} - - # 支持两种配置格式: - # 1. 单个账户格式:{"email": "xxx", "password": "xxx"} - # 2. 多账户格式:{"accounts": [{"email": "xxx", "password": "xxx"}]} - if "email" in self.config and "password" in self.config: - # 单个账户格式 - account = OutlookAccount.from_config(self.config) - if account.validate(): - self.accounts.append(account) - self._account_locks[account.email] = threading.Lock() - else: - logger.warning(f"无效的 Outlook 账户配置: {self.config}") - else: - # 多账户格式 - for account_config in self.config.get("accounts", []): - account = OutlookAccount.from_config(account_config) - if account.validate(): - self.accounts.append(account) - self._account_locks[account.email] = threading.Lock() - else: - logger.warning(f"无效的 Outlook 账户配置: {account_config}") - - if not self.accounts: - logger.warning("未配置有效的 Outlook 账户") - - # IMAP 连接限制(防止限流) - self._imap_semaphore = threading.Semaphore(5) - - # 验证码去重机制:email -> set of used codes - self._used_codes: Dict[str, set] = {} - - def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: - """ - 选择可用的 Outlook 账户 - - Args: - config: 配置参数(目前未使用) - - Returns: - 包含邮箱信息的字典: - - email: 邮箱地址 - - service_id: 账户邮箱(同 email) - - account: 账户信息 - """ - if not self.accounts: - self.update_status(False, EmailServiceError("没有可用的 Outlook 账户")) - raise EmailServiceError("没有可用的 Outlook 账户") - - # 轮询选择账户 - with threading.Lock(): - account = self.accounts[self._current_account_index] - self._current_account_index = (self._current_account_index + 1) % len(self.accounts) - - email_info = { - "email": account.email, - "service_id": account.email, # 对于 Outlook,service_id 就是邮箱地址 - "account": { - "email": account.email, - "has_oauth": account.has_oauth() - } - } - - logger.info(f"选择 Outlook 账户: {account.email}") - self.update_status(True) - return email_info - - def get_verification_code( - self, - email: str, - email_id: str = None, - timeout: int = None, - pattern: str = OTP_CODE_PATTERN, - otp_sent_at: Optional[float] = None, - ) -> Optional[str]: - """ - 从 Outlook 邮箱获取验证码 - - Args: - email: 邮箱地址 - email_id: 未使用(对于 Outlook,email 就是标识) - timeout: 超时时间(秒),默认使用配置值 - pattern: 验证码正则表达式 - otp_sent_at: OTP 发送时间戳,用于过滤旧邮件 - - Returns: - 验证码字符串,如果超时或未找到返回 None - """ - # 查找对应的账户 - account = None - for acc in self.accounts: - if acc.email.lower() == email.lower(): - account = acc - break - - if not account: - self.update_status(False, EmailServiceError(f"未找到邮箱对应的账户: {email}")) - return None - - # 从数据库获取验证码等待配置 - code_settings = get_email_code_settings() - actual_timeout = timeout or code_settings["timeout"] - poll_interval = code_settings["poll_interval"] - - logger.info(f"[{email}] 开始获取验证码,超时 {actual_timeout}s,OTP发送时间: {otp_sent_at}") - - # 初始化验证码去重集合 - if email not in self._used_codes: - self._used_codes[email] = set() - used_codes = self._used_codes[email] - - # 计算最小时间戳(留出 60 秒时钟偏差) - min_timestamp = (otp_sent_at - 60) if otp_sent_at else 0 - - start_time = time.time() - poll_count = 0 - - while time.time() - start_time < actual_timeout: - poll_count += 1 - loop_start = time.time() - - # 渐进式邮件检查:前 3 次只检查未读,之后检查全部 - only_unseen = poll_count <= 3 - - try: - connect_start = time.time() - with self._imap_semaphore: - with OutlookIMAPClient( - account, - host=self.config["imap_host"], - port=self.config["imap_port"], - timeout=10 - ) as client: - connect_elapsed = time.time() - connect_start - logger.debug(f"[{email}] IMAP 连接耗时 {connect_elapsed:.2f}s") - - # 搜索邮件 - search_start = time.time() - emails = client.get_recent_emails(count=15, only_unseen=only_unseen) - search_elapsed = time.time() - search_start - logger.debug(f"[{email}] 搜索到 {len(emails)} 封邮件(未读={only_unseen}),耗时 {search_elapsed:.2f}s") - - for mail in emails: - # 时间戳过滤 - mail_ts = mail.get("date_timestamp", 0) - if min_timestamp > 0 and mail_ts > 0 and mail_ts < min_timestamp: - logger.debug(f"[{email}] 跳过旧邮件: {mail.get('subject', '')[:50]}") - continue - - # 检查是否是 OpenAI 验证邮件 - if not self._is_openai_verification_mail(mail, email): - continue - - # 提取验证码 - code = self._extract_code_from_mail(mail, pattern) - if code: - # 去重检查 - if code in used_codes: - logger.debug(f"[{email}] 跳过已使用的验证码: {code}") - continue - - used_codes.add(code) - elapsed = int(time.time() - start_time) - logger.info(f"[{email}] 找到验证码: {code},总耗时 {elapsed}s,轮询 {poll_count} 次") - self.update_status(True) - return code - - except Exception as e: - loop_elapsed = time.time() - loop_start - logger.warning(f"[{email}] 检查出错: {e},循环耗时 {loop_elapsed:.2f}s") - - # 等待下次轮询 - time.sleep(poll_interval) - - elapsed = int(time.time() - start_time) - logger.warning(f"[{email}] 验证码超时 ({actual_timeout}s),共轮询 {poll_count} 次") - return None - - def list_emails(self, **kwargs) -> List[Dict[str, Any]]: - """ - 列出所有可用的 Outlook 账户 - - Returns: - 账户列表 - """ - return [ - { - "email": account.email, - "id": account.email, - "has_oauth": account.has_oauth(), - "type": "outlook" - } - for account in self.accounts - ] - - def delete_email(self, email_id: str) -> bool: - """ - 删除邮箱(对于 Outlook,不支持删除账户) - - Args: - email_id: 邮箱地址 - - Returns: - False(Outlook 不支持删除账户) - """ - logger.warning(f"Outlook 服务不支持删除账户: {email_id}") - return False - - def check_health(self) -> bool: - """检查 Outlook 服务是否可用""" - if not self.accounts: - self.update_status(False, EmailServiceError("没有配置的账户")) - return False - - # 测试第一个账户的连接 - test_account = self.accounts[0] - try: - with self._imap_semaphore: - with OutlookIMAPClient( - test_account, - host=self.config["imap_host"], - port=self.config["imap_port"], - timeout=10 - ) as client: - # 尝试列出邮箱(快速测试) - client._conn.select("INBOX", readonly=True) - self.update_status(True) - return True - except Exception as e: - logger.warning(f"Outlook 健康检查失败 ({test_account.email}): {e}") - self.update_status(False, e) - return False - - def _is_oai_mail(self, mail: Dict[str, Any]) -> bool: - """判断是否为 OpenAI 相关邮件(旧方法,保留兼容)""" - combined = f"{mail.get('from', '')} {mail.get('subject', '')} {mail.get('body', '')}".lower() - keywords = ["openai", "chatgpt", "verification", "验证码", "code"] - return any(keyword in combined for keyword in keywords) - - def _is_openai_verification_mail( - self, - mail: Dict[str, Any], - target_email: str = None - ) -> bool: - """ - 严格判断是否为 OpenAI 验证邮件 - - Args: - mail: 邮件信息字典 - target_email: 目标邮箱地址(用于验证收件人) - - Returns: - 是否为 OpenAI 验证邮件 - """ - sender = mail.get("from", "").lower() - - # 1. 发件人必须是 OpenAI - valid_senders = OPENAI_EMAIL_SENDERS - if not any(s in sender for s in valid_senders): - logger.debug(f"邮件发件人非 OpenAI: {sender}") - return False - - # 2. 主题或正文包含验证关键词 - subject = mail.get("subject", "").lower() - body = mail.get("body", "").lower() - verification_keywords = OPENAI_VERIFICATION_KEYWORDS - combined = f"{subject} {body}" - if not any(kw in combined for kw in verification_keywords): - logger.debug(f"邮件未包含验证关键词: {subject[:50]}") - return False - - # 3. 验证收件人(可选) - if target_email: - recipients = f"{mail.get('to', '')} {mail.get('delivered_to', '')} {mail.get('x_original_to', '')}".lower() - if target_email.lower() not in recipients: - logger.debug(f"邮件收件人不匹配: {recipients[:50]}") - return False - - logger.debug(f"识别为 OpenAI 验证邮件: {subject[:50]}") - return True - - def _extract_code_from_mail( - self, - mail: Dict[str, Any], - fallback_pattern: str = OTP_CODE_PATTERN - ) -> Optional[str]: - """ - 从邮件中提取验证码 - - 优先级: - 1. 从主题提取(6位数字) - 2. 从正文用语义正则提取(如 "code is 123456") - 3. 兜底:任意 6 位数字 - - Args: - mail: 邮件信息字典 - fallback_pattern: 兜底正则表达式 - - Returns: - 验证码字符串,如果未找到返回 None - """ - # 编译正则 - re_simple = re.compile(OTP_CODE_SIMPLE_PATTERN) - re_semantic = re.compile(OTP_CODE_SEMANTIC_PATTERN, re.IGNORECASE) - - # 1. 主题优先 - subject = mail.get("subject", "") - match = re_simple.search(subject) - if match: - code = match.group(1) - logger.debug(f"从主题提取验证码: {code}") - return code - - # 2. 正文语义匹配 - body = mail.get("body", "") - match = re_semantic.search(body) - if match: - code = match.group(1) - logger.debug(f"从正文语义提取验证码: {code}") - return code - - # 3. 兜底:任意 6 位数字 - match = re_simple.search(body) - if match: - code = match.group(1) - logger.debug(f"从正文兜底提取验证码: {code}") - return code - - return None - - def get_account_stats(self) -> Dict[str, Any]: - """获取账户统计信息""" - total = len(self.accounts) - oauth_count = sum(1 for acc in self.accounts if acc.has_oauth()) - - return { - "total_accounts": total, - "oauth_accounts": oauth_count, - "password_accounts": total - oauth_count, - "accounts": [ - { - "email": acc.email, - "has_oauth": acc.has_oauth() - } - for acc in self.accounts - ] - } - - def add_account(self, account_config: Dict[str, Any]) -> bool: - """添加新的 Outlook 账户""" - try: - account = OutlookAccount.from_config(account_config) - if not account.validate(): - return False - - self.accounts.append(account) - self._account_locks[account.email] = threading.Lock() - logger.info(f"添加 Outlook 账户: {account.email}") - return True - except Exception as e: - logger.error(f"添加 Outlook 账户失败: {e}") - return False - - def remove_account(self, email: str) -> bool: - """移除 Outlook 账户""" - for i, acc in enumerate(self.accounts): - if acc.email.lower() == email.lower(): - self.accounts.pop(i) - self._account_locks.pop(email, None) - logger.info(f"移除 Outlook 账户: {email}") - return True - return False \ No newline at end of file diff --git a/src/services/temp_mail.py b/src/services/temp_mail.py index 150a88e..e8660cc 100644 --- a/src/services/temp_mail.py +++ b/src/services/temp_mail.py @@ -15,7 +15,7 @@ from email.policy import default as email_policy from html import unescape from typing import Optional, Dict, Any, List -from .base import BaseEmailService, EmailServiceError, EmailServiceType, RateLimitedEmailServiceError, get_email_code_settings +from .base import BaseEmailService, EmailServiceError, EmailServiceType, OTPNoOpenAISenderEmailServiceError, RateLimitedEmailServiceError, get_email_code_settings from ..core.http_client import HTTPClient, RequestConfig from ..config.constants import OTP_CODE_PATTERN @@ -95,10 +95,7 @@ class TempMailService(BaseEmailService): charset = part.get_content_charset() or "utf-8" text = payload.decode(charset, errors="replace") if payload else "" except Exception: - try: - text = part.get_content() - except Exception: - text = "" + text = str(part.get_payload() or "") if content_type == "text/html": text = re.sub(r"<[^>]+>", " ", text) @@ -109,10 +106,7 @@ class TempMailService(BaseEmailService): charset = message.get_content_charset() or "utf-8" body = payload.decode(charset, errors="replace") if payload else "" except Exception: - try: - body = message.get_content() - except Exception: - body = str(message.get_payload() or "") + body = str(message.get_payload() or "") if "html" in (message.get_content_type() or "").lower(): body = re.sub(r"<[^>]+>", " ", body) @@ -346,6 +340,17 @@ class TempMailService(BaseEmailService): ) if isinstance(item, dict) else None, ) + if ordered_mails: + if not self._batch_has_openai_sender( + ordered_mails, + lambda item: ( + item.get("from") + or item.get("sender") + or item.get("fromAddress") + ) if isinstance(item, dict) else None, + ): + raise OTPNoOpenAISenderEmailServiceError() + for mail in ordered_mails: mail_id = mail.get("id") if not mail_id or mail_id in seen_mail_ids: @@ -368,7 +373,7 @@ class TempMailService(BaseEmailService): content = f"{sender}\n{subject}\n{body_text}\n{raw_text}".strip() # 只处理 OpenAI 邮件 - if "openai" not in sender and "openai" not in content.lower(): + if not self._is_openai_candidate_message(sender, subject, body_text, raw_text): continue code = self._extract_otp_from_text(content, pattern) @@ -380,6 +385,8 @@ class TempMailService(BaseEmailService): return code except Exception as e: + if isinstance(e, OTPNoOpenAISenderEmailServiceError): + raise logger.debug(f"检查 TempMail 邮件时出错: {e}") time.sleep(poll_interval) diff --git a/src/services/tempmail.py b/src/services/tempmail.py index 2a65cb2..ee5884c 100644 --- a/src/services/tempmail.py +++ b/src/services/tempmail.py @@ -5,10 +5,10 @@ Tempmail.lol 邮箱服务实现 import re import time import logging -from typing import Optional, Dict, Any, List +from typing import Callable, Optional, Dict, Any, List from datetime import datetime, timezone -from .base import BaseEmailService, EmailServiceError, EmailServiceType, get_email_code_settings +from .base import BaseEmailService, EmailServiceError, EmailServiceType, OTPNoOpenAISenderEmailServiceError, get_email_code_settings from ..core.http_client import HTTPClient, RequestConfig from ..config.constants import OTP_CODE_PATTERN @@ -247,6 +247,13 @@ class TempmailService(BaseEmailService): lambda item: item.get("date") if isinstance(item, dict) else None, ) + if ordered_emails: + if not self._batch_has_openai_sender( + ordered_emails, + lambda item: item.get("from") if isinstance(item, dict) else None, + ): + raise OTPNoOpenAISenderEmailServiceError() + for msg in ordered_emails: if not isinstance(msg, dict): continue @@ -276,7 +283,7 @@ class TempmailService(BaseEmailService): content = "\n".join([sender, subject, body, html]) # 检查是否是 OpenAI 邮件 - if "openai" not in sender and "openai" not in content.lower(): + if not self._is_openai_candidate_message(sender, subject, body, html): continue # 提取验证码 @@ -290,6 +297,8 @@ class TempmailService(BaseEmailService): return code except Exception as e: + if isinstance(e, OTPNoOpenAISenderEmailServiceError): + raise logger.debug(f"检查邮件时出错: {e}") # 等待一段时间再检查 @@ -370,7 +379,7 @@ class TempmailService(BaseEmailService): self, email: str, token: str, - callback: callable = None, + callback: Optional[Callable[[Dict[str, Any]], None]] = None, timeout: int = 120 ) -> Optional[str]: """ diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index b678603..4d6136b 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -285,6 +285,12 @@ def _normalize_email_service_config( if service_type == EmailServiceType.MOE_MAIL: if 'domain' in normalized and 'default_domain' not in normalized: normalized['default_domain'] = normalized.pop('domain') + elif service_type == EmailServiceType.OUTLOOK: + settings = get_settings() + normalized.setdefault('provider_priority', settings.outlook_provider_priority) + normalized.setdefault('health_failure_threshold', settings.outlook_health_failure_threshold) + normalized.setdefault('health_disable_duration', settings.outlook_health_disable_duration) + normalized.setdefault('require_recipient_match', settings.outlook_require_recipient_match) elif service_type in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL): if 'default_domain' in normalized and 'domain' not in normalized: normalized['domain'] = normalized.pop('default_domain') diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index 2a43ab0..3186812 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -4,7 +4,7 @@ import logging import os -from typing import Optional +from typing import List, Optional from fastapi import APIRouter, HTTPException from pydantic import BaseModel @@ -108,6 +108,8 @@ async def get_all_settings(): "email_code": { "timeout": settings.email_code_timeout, "poll_interval": settings.email_code_poll_interval, + "resend_max_retries": settings.email_code_resend_max_retries, + "non_openai_sender_resend_max_retries": settings.email_code_non_openai_sender_resend_max_retries, }, } @@ -387,6 +389,7 @@ class EmailCodeSettings(BaseModel): timeout: int = 120 # 验证码等待超时(秒) poll_interval: int = 3 # 验证码轮询间隔(秒) resend_max_retries: int = 2 # 收件箱未找到验证码时最多重新发送次数 + non_openai_sender_resend_max_retries: int = 1 # 非 OpenAI 发件人导致的最多重新发送次数 @router.get("/tempmail") @@ -425,6 +428,7 @@ async def get_email_code_settings(): "timeout": settings.email_code_timeout, "poll_interval": settings.email_code_poll_interval, "resend_max_retries": settings.email_code_resend_max_retries, + "non_openai_sender_resend_max_retries": settings.email_code_non_openai_sender_resend_max_retries, } @@ -439,11 +443,14 @@ async def update_email_code_settings(request: EmailCodeSettings): if request.resend_max_retries < 0 or request.resend_max_retries > 10: raise HTTPException(status_code=400, detail="重发次数必须在 0-10 之间") + if request.non_openai_sender_resend_max_retries < 0 or request.non_openai_sender_resend_max_retries > 10: + raise HTTPException(status_code=400, detail="非 OpenAI 发件人重发次数必须在 0-10 之间") update_settings( email_code_timeout=request.timeout, email_code_poll_interval=request.poll_interval, email_code_resend_max_retries=request.resend_max_retries, + email_code_non_openai_sender_resend_max_retries=request.non_openai_sender_resend_max_retries, ) return {"success": True, "message": "验证码等待设置已更新"} @@ -842,6 +849,10 @@ async def disable_proxy(proxy_id: int): class OutlookSettings(BaseModel): """Outlook 设置""" default_client_id: Optional[str] = None + provider_priority: Optional[List[str]] = None + health_failure_threshold: Optional[int] = None + health_disable_duration: Optional[int] = None + require_recipient_match: Optional[bool] = None @router.get("/outlook") @@ -854,6 +865,7 @@ async def get_outlook_settings(): "provider_priority": settings.outlook_provider_priority, "health_failure_threshold": settings.outlook_health_failure_threshold, "health_disable_duration": settings.outlook_health_disable_duration, + "require_recipient_match": settings.outlook_require_recipient_match, } @@ -864,6 +876,14 @@ async def update_outlook_settings(request: OutlookSettings): if request.default_client_id is not None: update_dict["outlook_default_client_id"] = request.default_client_id + if request.provider_priority is not None: + update_dict["outlook_provider_priority"] = request.provider_priority + if request.health_failure_threshold is not None: + update_dict["outlook_health_failure_threshold"] = request.health_failure_threshold + if request.health_disable_duration is not None: + update_dict["outlook_health_disable_duration"] = request.health_disable_duration + if request.require_recipient_match is not None: + update_dict["outlook_require_recipient_match"] = request.require_recipient_match if update_dict: update_settings(**update_dict) diff --git a/static/js/accounts.js b/static/js/accounts.js index ac7349d..75c56c7 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -33,8 +33,10 @@ const elements = { exportBtn: document.getElementById('export-btn'), exportMenu: document.getElementById('export-menu'), selectAll: document.getElementById('select-all'), + firstPage: document.getElementById('first-page'), prevPage: document.getElementById('prev-page'), nextPage: document.getElementById('next-page'), + lastPage: document.getElementById('last-page'), pageInfo: document.getElementById('page-info'), detailModal: document.getElementById('detail-modal'), modalBody: document.getElementById('modal-body'), @@ -150,6 +152,13 @@ function initEventListeners() { }); // 分页 + elements.firstPage.addEventListener('click', () => { + if (currentPage > 1 && !isLoading) { + currentPage = 1; + loadAccounts(); + } + }); + elements.prevPage.addEventListener('click', () => { if (currentPage > 1 && !isLoading) { currentPage--; @@ -165,6 +174,14 @@ function initEventListeners() { } }); + elements.lastPage.addEventListener('click', () => { + const totalPages = Math.ceil(totalAccounts / pageSize); + if (currentPage < totalPages && !isLoading) { + currentPage = totalPages; + loadAccounts(); + } + }); + // 导出 elements.exportBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -427,8 +444,10 @@ function togglePassword(element, password) { function updatePagination() { const totalPages = Math.max(1, Math.ceil(totalAccounts / pageSize)); + elements.firstPage.disabled = currentPage <= 1; elements.prevPage.disabled = currentPage <= 1; elements.nextPage.disabled = currentPage >= totalPages; + elements.lastPage.disabled = currentPage >= totalPages; elements.pageInfo.textContent = `第 ${currentPage} 页 / 共 ${totalPages} 页`; } diff --git a/static/js/email_services.js b/static/js/email_services.js index 060a9c6..f6b3c93 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -16,9 +16,11 @@ const elements = { tempmailStatus: document.getElementById('tempmail-status'), totalEnabled: document.getElementById('total-enabled'), - // Outlook 导入 - toggleOutlookImport: document.getElementById('toggle-outlook-import'), - outlookImportBody: document.getElementById('outlook-import-body'), + // Outlook 导入模态框 + addOutlookBtn: document.getElementById('add-outlook-btn'), + outlookImportModal: document.getElementById('outlook-import-modal'), + closeOutlookImportModal: document.getElementById('close-outlook-import-modal'), + cancelOutlookImportBtn: document.getElementById('cancel-outlook-import-btn'), outlookImportData: document.getElementById('outlook-import-data'), outlookImportEnabled: document.getElementById('outlook-import-enabled'), outlookImportPriority: document.getElementById('outlook-import-priority'), @@ -96,11 +98,19 @@ document.addEventListener('DOMContentLoaded', () => { // 事件监听 function initEventListeners() { - // Outlook 导入展开/收起 - elements.toggleOutlookImport.addEventListener('click', () => { - const isHidden = elements.outlookImportBody.style.display === 'none'; - elements.outlookImportBody.style.display = isHidden ? 'block' : 'none'; - elements.toggleOutlookImport.textContent = isHidden ? '收起' : '展开'; + // Outlook 添加邮箱按钮 → 打开模态框 + elements.addOutlookBtn.addEventListener('click', () => { + elements.outlookImportData.value = ''; + elements.importResult.style.display = 'none'; + elements.outlookImportModal.classList.add('active'); + }); + + // 关闭 Outlook 导入模态框 + const closeOutlookModal = () => elements.outlookImportModal.classList.remove('active'); + elements.closeOutlookImportModal.addEventListener('click', closeOutlookModal); + elements.cancelOutlookImportBtn.addEventListener('click', closeOutlookModal); + elements.outlookImportModal.addEventListener('click', (e) => { + if (e.target === elements.outlookImportModal) closeOutlookModal(); }); // Outlook 导入 diff --git a/static/js/settings.js b/static/js/settings.js index f549c62..0902f24 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -400,6 +400,7 @@ async function loadSettings() { document.getElementById('email-code-timeout').value = data.email_code.timeout || 120; document.getElementById('email-code-poll-interval').value = data.email_code.poll_interval || 3; document.getElementById('email-code-resend-max-retries').value = data.email_code.resend_max_retries ?? 2; + document.getElementById('email-code-non-openai-sender-resend-max-retries').value = data.email_code.non_openai_sender_resend_max_retries ?? 1; } // 加载 Outlook 设置 @@ -565,16 +566,22 @@ async function handleSaveEmailCode(e) { } const resendMaxRetries = parseInt(document.getElementById('email-code-resend-max-retries').value); + const nonOpenaiSenderResendMaxRetries = parseInt(document.getElementById('email-code-non-openai-sender-resend-max-retries').value); if (resendMaxRetries < 0 || resendMaxRetries > 10) { toast.error('重发次数必须在 0-10 之间'); return; } + if (nonOpenaiSenderResendMaxRetries < 0 || nonOpenaiSenderResendMaxRetries > 10) { + toast.error('非 OpenAI 发件人重发次数必须在 0-10 之间'); + return; + } const data = { timeout: timeout, poll_interval: pollInterval, - resend_max_retries: resendMaxRetries + resend_max_retries: resendMaxRetries, + non_openai_sender_resend_max_retries: nonOpenaiSenderResendMaxRetries }; try { diff --git a/templates/accounts.html b/templates/accounts.html index 375b8ff..fe6d91d 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -198,6 +198,9 @@ diff --git a/templates/email_services.html b/templates/email_services.html index d648e98..9df9121 100644 --- a/templates/email_services.html +++ b/templates/email_services.html @@ -54,45 +54,6 @@ - -
-
-

📥 Outlook 批量导入

- -
- -
-
@@ -133,7 +94,10 @@

📧 Outlook 账户列表

- +
+ + +
@@ -556,7 +520,49 @@
- + + + + diff --git a/templates/settings.html b/templates/settings.html index de26705..4708103 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -581,6 +581,12 @@ MyProxy|socks5://user:pass@host:port"> 收件箱未找到验证码时,最多重新触发发送的次数
+ +
+ + + 检测到疑似干扰邮件但发件人不是 OpenAI 时,单独允许重新触发发送的次数 +
diff --git a/check_otp_timing.py b/tests/check_otp_timing.py similarity index 100% rename from check_otp_timing.py rename to tests/check_otp_timing.py diff --git a/probe_tempmail.py b/tests/probe_tempmail.py similarity index 100% rename from probe_tempmail.py rename to tests/probe_tempmail.py diff --git a/tests/test_mail_openai_detection.py b/tests/test_mail_openai_detection.py new file mode 100644 index 0000000..3f61fa6 --- /dev/null +++ b/tests/test_mail_openai_detection.py @@ -0,0 +1,45 @@ +from src.services import EmailServiceType +from src.services.base import BaseEmailService + + +class DummyEmailService(BaseEmailService): + def __init__(self): + super().__init__(EmailServiceType.TEMPMAIL, name="dummy") + + def create_email(self, config=None): + return {"email": "dummy@example.com", "service_id": "dummy"} + + def get_verification_code(self, **kwargs): + return None + + def list_emails(self, **kwargs): + return [] + + def delete_email(self, email_id: str) -> bool: + return True + + def check_health(self) -> bool: + return True + + +def test_is_openai_candidate_message_supports_sender_and_content_paths(): + service = DummyEmailService() + + assert service._is_openai_candidate_message("noreply@openai.com", "hello") is True + assert service._is_openai_candidate_message("notice@example.com", "Your OpenAI verification code") is True + assert service._is_openai_candidate_message("notice@example.com", "newsletter") is False + + +def test_batch_has_openai_sender_only_checks_sender_fields(): + service = DummyEmailService() + batch = [ + {"from": "notice@example.com", "body": "openai mentioned in content"}, + {"from": "alerts@example.com", "body": "still not sender"}, + ] + + assert service._batch_has_openai_sender(batch, lambda item: item.get("from")) is False + assert service._batch_has_openai_sender( + batch + [{"from": "otp@tm1.openai.com", "body": "code"}], + lambda item: item.get("from"), + ) is True + diff --git a/tests/test_outlook_service_config_and_health.py b/tests/test_outlook_service_config_and_health.py new file mode 100644 index 0000000..b209ac0 --- /dev/null +++ b/tests/test_outlook_service_config_and_health.py @@ -0,0 +1,127 @@ +from src.services import EmailServiceType +from src.services.outlook.base import EmailMessage, ProviderType +from src.services.outlook.email_parser import EmailParser +from src.services.outlook.health_checker import HealthChecker +from src.services.outlook.service import OutlookService +from src.services.base import OTPNoOpenAISenderEmailServiceError +from src.web.routes import registration as registration_routes + + +def test_health_checker_is_scoped_by_account_email(): + checker = HealthChecker(failure_threshold=1, disable_duration=120) + + checker.record_failure(ProviderType.IMAP_OLD, "boom", account_email="a@example.com") + + assert checker.is_available(ProviderType.IMAP_OLD, "b@example.com") is True + assert checker.is_available(ProviderType.IMAP_OLD, "a@example.com") is False + + account_a_status = checker.get_all_health_status("a@example.com") + assert account_a_status[ProviderType.IMAP_OLD.value]["status"] == "disabled" + + +def test_email_parser_respects_recipient_match_switch(): + parser = EmailParser() + mail = EmailMessage( + id="m1", + subject="Your verification code", + sender="noreply@openai.com", + recipients=["other@example.com"], + body="Your code is 123456", + received_timestamp=123, + ) + + assert parser.is_openai_verification_email( + mail, + target_email="target@example.com", + require_recipient_match=True, + ) is False + assert parser.is_openai_verification_email( + mail, + target_email="target@example.com", + require_recipient_match=False, + ) is True + + assert parser.find_verification_code_in_emails( + [mail], + target_email="target@example.com", + require_recipient_match=True, + ) is None + assert parser.find_verification_code_in_emails( + [mail], + target_email="target@example.com", + require_recipient_match=False, + ) == "123456" + + +def test_normalize_outlook_config_inherits_global_settings(monkeypatch): + class DummySettings: + outlook_provider_priority = ["imap_old", "graph_api"] + outlook_health_failure_threshold = 4 + outlook_health_disable_duration = 90 + outlook_require_recipient_match = True + + monkeypatch.setattr(registration_routes, "get_settings", lambda: DummySettings()) + + normalized = registration_routes._normalize_email_service_config( + EmailServiceType.OUTLOOK, + { + "email": "user@example.com", + "require_recipient_match": False, + }, + proxy_url="http://127.0.0.1:7890", + ) + + assert normalized["provider_priority"] == ["imap_old", "graph_api"] + assert normalized["health_failure_threshold"] == 4 + assert normalized["health_disable_duration"] == 90 + assert normalized["require_recipient_match"] is False + assert normalized["proxy_url"] == "http://127.0.0.1:7890" + + +def test_email_parser_has_openai_sender(): + parser = EmailParser() + mails = [ + EmailMessage(id="x1", subject="hello", sender="notice@example.com"), + EmailMessage(id="x2", subject="otp", sender="no-reply@tm.openai.com"), + ] + assert parser.has_openai_sender(mails) is True + + +def test_outlook_service_returns_early_when_batch_has_no_openai_sender(monkeypatch): + service = OutlookService( + { + "email": "user@example.com", + "password": "pwd", + }, + name="outlook-test", + ) + + non_openai_emails = [ + EmailMessage( + id="m1", + subject="newsletter", + sender="newsletter@example.com", + recipients=["user@example.com"], + body="no code", + received_timestamp=100, + ) + ] + + monkeypatch.setattr( + service, + "_try_providers_for_emails", + lambda *args, **kwargs: non_openai_emails, + ) + + try: + service.get_verification_code( + email="user@example.com", + timeout=30, + otp_sent_at=0, + ) + except OTPNoOpenAISenderEmailServiceError: + return + + raise AssertionError("expected OTPNoOpenAISenderEmailServiceError") + + diff --git a/tests/test_registration_otp_phase.py b/tests/test_registration_otp_phase.py index 729dd86..a5a3678 100644 --- a/tests/test_registration_otp_phase.py +++ b/tests/test_registration_otp_phase.py @@ -2,6 +2,7 @@ import src.core.register as register_module from src.core.register import ( ERROR_OTP_TIMEOUT_SECONDARY, PhaseContext, + PhaseResult, RegistrationEngine, ) from src.services import EmailServiceType @@ -13,6 +14,10 @@ class DummySettings: openai_token_url = "https://token.example.test" openai_redirect_uri = "https://callback.example.test" openai_scope = "openid profile email" + email_code_timeout = 120 + email_code_poll_interval = 3 + email_code_resend_max_retries = 2 + email_code_non_openai_sender_resend_max_retries = 1 class FakeEmailService: @@ -26,6 +31,12 @@ class FakeEmailService: return self.code +class FastResendEmailService(FakeEmailService): + def get_verification_code(self, **kwargs): + self.calls.append(kwargs) + raise register_module.OTPNoOpenAISenderEmailServiceError() + + class FakeCookies: def __init__(self, values): self.values = values @@ -56,11 +67,67 @@ class FakeResponse: return self._json_payload -def _build_engine(monkeypatch, email_service): - monkeypatch.setattr(register_module, "get_settings", lambda: DummySettings()) +def _build_engine(monkeypatch, email_service, **setting_overrides): + def _settings(): + settings = DummySettings() + for key, value in setting_overrides.items(): + setattr(settings, key, value) + return settings + + monkeypatch.setattr(register_module, "get_settings", _settings) return RegistrationEngine(email_service=email_service) +def _build_failed_phase(error_code: str, error_message: str) -> PhaseResult: + return PhaseResult( + phase=register_module.PHASE_OTP_SECONDARY, + success=False, + error_code=error_code, + error_message=error_message, + retryable=True, + next_action="resend_otp", + ) + + +def _prepare_engine_for_run(monkeypatch, phase_results, **setting_overrides): + engine = _build_engine( + monkeypatch, + FakeEmailService(code=None), + **setting_overrides, + ) + monkeypatch.setattr(register_module.time, "time", lambda: 100.0) + + send_calls = [] + phase_iter = iter(phase_results) + + def fake_phase_email_prepare(): + engine.email = "tester@example.com" + engine.email_info = {"service_id": "svc-1"} + return True + + monkeypatch.setattr(engine, "_phase_email_prepare", fake_phase_email_prepare) + monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US")) + monkeypatch.setattr(engine, "_init_session", lambda: True) + monkeypatch.setattr(engine, "_start_oauth", lambda: True) + monkeypatch.setattr(engine, "_get_device_id", lambda: "did-1") + monkeypatch.setattr(engine, "_check_sentinel", lambda _did: None) + monkeypatch.setattr( + engine, + "_submit_signup_form", + lambda _did, _sen_token: type("SignupResult", (), {"success": True, "error_message": ""})(), + ) + monkeypatch.setattr(engine, "_register_password", lambda: (True, "pass-123")) + + def fake_send_verification_code(): + send_calls.append("send") + engine._otp_sent_at = 100.0 + return True + + monkeypatch.setattr(engine, "_send_verification_code", fake_send_verification_code) + monkeypatch.setattr(engine, "_phase_otp_secondary", lambda *_args, **_kwargs: (None, next(phase_iter))) + return engine, send_calls + + def test_phase_otp_secondary_uses_remaining_budget_from_start_timestamp(monkeypatch): email_service = FakeEmailService(code="654321") engine = _build_engine(monkeypatch, email_service) @@ -101,6 +168,82 @@ def test_phase_otp_secondary_returns_dedicated_timeout_error_code(monkeypatch): assert engine.phase_history[0].error_code == ERROR_OTP_TIMEOUT_SECONDARY +def test_phase_otp_secondary_maps_no_openai_sender_to_resend_action(monkeypatch): + email_service = FastResendEmailService(code=None) + engine = _build_engine(monkeypatch, email_service) + engine.email = "tester@example.com" + engine.email_info = {"service_id": "svc-1"} + + monkeypatch.setattr(register_module.time, "time", lambda: 120.0) + + code, phase_result = engine._phase_otp_secondary( + PhaseContext(otp_sent_at=80.0), + started_at=100.0, + ) + + assert code is None + assert phase_result.success is False + assert phase_result.error_code == "OTP_NO_OPENAI_SENDER" + assert phase_result.retryable is True + assert phase_result.next_action == "resend_otp" + + +def test_run_uses_dedicated_budget_for_non_openai_sender_resends(monkeypatch): + engine, send_calls = _prepare_engine_for_run( + monkeypatch, + [ + _build_failed_phase("OTP_NO_OPENAI_SENDER", "detected non-openai sender"), + _build_failed_phase(ERROR_OTP_TIMEOUT_SECONDARY, "timeout after dedicated resend"), + ], + email_code_resend_max_retries=0, + email_code_non_openai_sender_resend_max_retries=1, + ) + + result = engine.run() + + assert result.success is False + assert result.error_code == ERROR_OTP_TIMEOUT_SECONDARY + assert len(send_calls) == 2 + + +def test_run_stops_when_non_openai_sender_budget_is_exhausted(monkeypatch): + engine, send_calls = _prepare_engine_for_run( + monkeypatch, + [ + _build_failed_phase("OTP_NO_OPENAI_SENDER", "detected non-openai sender"), + _build_failed_phase("OTP_NO_OPENAI_SENDER", "detected non-openai sender again"), + ], + email_code_resend_max_retries=2, + email_code_non_openai_sender_resend_max_retries=1, + ) + + result = engine.run() + + assert result.success is False + assert result.error_code == "OTP_NO_OPENAI_SENDER" + assert len(send_calls) == 2 + + +def test_run_keeps_timeout_budget_after_non_openai_sender_resend(monkeypatch): + engine, send_calls = _prepare_engine_for_run( + monkeypatch, + [ + _build_failed_phase(ERROR_OTP_TIMEOUT_SECONDARY, "timeout #1"), + _build_failed_phase("OTP_NO_OPENAI_SENDER", "detected non-openai sender"), + _build_failed_phase(ERROR_OTP_TIMEOUT_SECONDARY, "timeout #2"), + _build_failed_phase(ERROR_OTP_TIMEOUT_SECONDARY, "timeout #3"), + ], + email_code_resend_max_retries=2, + email_code_non_openai_sender_resend_max_retries=1, + ) + + result = engine.run() + + assert result.success is False + assert result.error_code == ERROR_OTP_TIMEOUT_SECONDARY + assert len(send_calls) == 4 + + def test_advance_login_authorization_sets_otp_anchor_before_password_submit(monkeypatch): email_service = FakeEmailService(code=None) engine = _build_engine(monkeypatch, email_service) diff --git a/tests_runtime/runtime_functionality_report_1774308869.json b/tests_runtime/runtime_functionality_report_1774308869.json deleted file mode 100644 index a59b599..0000000 --- a/tests_runtime/runtime_functionality_report_1774308869.json +++ /dev/null @@ -1,292 +0,0 @@ -{ - "mode": "live", - "base_url": "http://127.0.0.1:15555", - "db_path": "/Volumes/Work/code/codex-manager/tests_runtime/e2e_runtime_1774308869.db", - "health": { - "status_code": 200, - "body": { - "total": 4, - "tasks": [ - { - "id": 4, - "task_uuid": "9079068e-e3f5-4fa7-8e1c-810ce1c352da", - "status": "completed", - "email_service_id": null, - "proxy": null, - "logs": null, - "result": null, - "error_message": null, - "created_at": "2026-03-23T23:34:58.715238", - "started_at": "2026-03-23T23:34:58.718370", - "completed_at": "2026-03-23T23:34:58.718376" - } - ] - } - }, - "create": { - "task": { - "id": 5, - "task_uuid": "a8f4da41-354c-4d89-9634-c582a032c70b", - "status": "pending", - "email_service_id": null, - "proxy": null, - "logs": null, - "result": null, - "error_message": null, - "created_at": "2026-03-23T23:35:28.629402", - "started_at": null, - "completed_at": null - }, - "batch_id": "2e8cfce4-bf20-4f0b-8839-a94e8e141472", - "checks": { - "seeded_account_email": "mock-seeded-a8f4da41@example.test", - "tokenless_account_email": "mock-tokenless-a8f4da41@example.test", - "partial_account_email": "mock-partial-a8f4da41@example.test", - "outlook_account_email": "mock-outlook-a8f4da41@example.test", - "backoff_service_name": "mock-backoff-a8f4da41" - } - }, - "websocket": { - "messages": [ - { - "type": "status", - "task_uuid": "a8f4da41-354c-4d89-9634-c582a032c70b", - "status": "pending" - }, - { - "type": "status", - "task_uuid": "a8f4da41-354c-4d89-9634-c582a032c70b", - "status": "running", - "timestamp": "2026-03-23T23:35:29.258537", - "email_service": "tempmail" - }, - { - "type": "log", - "task_uuid": "a8f4da41-354c-4d89-9634-c582a032c70b", - "message": "[模拟] 任务已启动,开始执行真实链路探针", - "timestamp": "2026-03-23T23:35:29.258717" - }, - { - "type": "log", - "task_uuid": "a8f4da41-354c-4d89-9634-c582a032c70b", - "message": "[模拟] Token 同步与 Outlook refresh_token 探针已写入数据库", - "timestamp": "2026-03-23T23:35:29.462037" - }, - { - "type": "log", - "task_uuid": "a8f4da41-354c-4d89-9634-c582a032c70b", - "message": "[模拟] OTP 超时退避 #1: failures=1, delay=30", - "timestamp": "2026-03-23T23:35:29.618496" - }, - { - "type": "log", - "task_uuid": "a8f4da41-354c-4d89-9634-c582a032c70b", - "message": "[模拟] OTP 超时退避 #2: failures=2, delay=60", - "timestamp": "2026-03-23T23:35:29.772745" - }, - { - "type": "log", - "task_uuid": "a8f4da41-354c-4d89-9634-c582a032c70b", - "message": "[模拟] OTP 超时退避 #3: failures=3, delay=3600", - "timestamp": "2026-03-23T23:35:29.926635" - }, - { - "type": "log", - "task_uuid": "a8f4da41-354c-4d89-9634-c582a032c70b", - "message": "[模拟] 批量计数探针已完成", - "timestamp": "2026-03-23T23:35:30.102423" - }, - { - "type": "status", - "task_uuid": "a8f4da41-354c-4d89-9634-c582a032c70b", - "status": "completed", - "timestamp": "2026-03-23T23:35:30.287066", - "email": "mock-seeded-a8f4da41@example.test", - "email_service": "tempmail" - } - ], - "log_count": 6, - "status_count": 3, - "live_log_count": 6, - "final_status": "completed" - }, - "task": { - "id": 5, - "task_uuid": "a8f4da41-354c-4d89-9634-c582a032c70b", - "status": "completed", - "email_service_id": null, - "proxy": null, - "logs": "[模拟] 任务已启动,开始执行真实链路探针\n[模拟] Token 同步与 Outlook refresh_token 探针已写入数据库\n[模拟] OTP 超时退避 #1: failures=1, delay=30\n[模拟] OTP 超时退避 #2: failures=2, delay=60\n[模拟] OTP 超时退避 #3: failures=3, delay=3600\n[模拟] 批量计数探针已完成\n[模拟] 任务完成,所有探针已收口", - "result": { - "email": "mock-seeded-a8f4da41@example.test", - "email_service": "tempmail", - "hardening_checks": { - "token_sync": { - "seeded_account_id": 4, - "tokenless_account_id": 5, - "partial_account_id": 6 - }, - "outlook_refresh": { - "service_id": 3, - "email": "mock-outlook-a8f4da41@example.test" - }, - "batch_counter": { - "batch_id": "2e8cfce4-bf20-4f0b-8839-a94e8e141472", - "task_uuids": [ - "03c182b4-d5d3-4939-b2a0-eda844c402d9", - "224f2a9f-c0f3-4d97-8e92-4c2e772a675b", - "6c4f0e18-47b1-473a-9cc5-83ef09e33ff8" - ], - "snapshot": { - "status": "completed", - "total": 3, - "completed": 3, - "success": 2, - "failed": 1, - "skipped": 0, - "cancelled": false, - "current_index": 0, - "finished": true, - "task_uuids": [ - "03c182b4-d5d3-4939-b2a0-eda844c402d9", - "224f2a9f-c0f3-4d97-8e92-4c2e772a675b", - "6c4f0e18-47b1-473a-9cc5-83ef09e33ff8" - ] - } - }, - "otp_timeout_backoff": { - "service_id": 4, - "states": [ - { - "failures": 1, - "delay_seconds": 30, - "opened_until": 1774308959.612146, - "retry_after": null, - "last_error": "模拟 OTP 超时 #1" - }, - { - "failures": 2, - "delay_seconds": 60, - "opened_until": 1774308989.7684338, - "retry_after": null, - "last_error": "模拟 OTP 超时 #2" - }, - { - "failures": 3, - "delay_seconds": 3600, - "opened_until": 1774312529.923651, - "retry_after": null, - "last_error": "模拟 OTP 超时 #3" - } - ] - } - } - }, - "error_message": null, - "created_at": "2026-03-23T23:35:28.629402", - "started_at": "2026-03-23T23:35:29.251251", - "completed_at": "2026-03-23T23:35:30.252298" - }, - "batch_api": { - "batch_id": "2e8cfce4-bf20-4f0b-8839-a94e8e141472", - "total": 3, - "completed": 3, - "success": 2, - "failed": 1, - "current_index": 0, - "cancelled": false, - "finished": true, - "progress": "3/3" - }, - "database": { - "task_uuid": "a8f4da41-354c-4d89-9634-c582a032c70b", - "batch_id": "2e8cfce4-bf20-4f0b-8839-a94e8e141472", - "seeded_account": { - "email": "mock-seeded-a8f4da41@example.test", - "access_token": "mock-access-token-seeded", - "refresh_token": "mock-refresh-token-seeded", - "token_sync_status": "pending" - }, - "tokenless_account": { - "email": "mock-tokenless-a8f4da41@example.test", - "access_token": "mock-access-token-updated", - "refresh_token": null, - "token_sync_status": "pending" - }, - "partial_account": { - "email": "mock-partial-a8f4da41@example.test", - "access_token": "mock-access-token-partial", - "refresh_token": "", - "token_sync_status": "pending" - }, - "task_result": { - "email": "mock-seeded-a8f4da41@example.test", - "email_service": "tempmail", - "hardening_checks": { - "token_sync": { - "seeded_account_id": 4, - "tokenless_account_id": 5, - "partial_account_id": 6 - }, - "outlook_refresh": { - "service_id": 3, - "email": "mock-outlook-a8f4da41@example.test" - }, - "batch_counter": { - "batch_id": "2e8cfce4-bf20-4f0b-8839-a94e8e141472", - "task_uuids": [ - "03c182b4-d5d3-4939-b2a0-eda844c402d9", - "224f2a9f-c0f3-4d97-8e92-4c2e772a675b", - "6c4f0e18-47b1-473a-9cc5-83ef09e33ff8" - ], - "snapshot": { - "status": "completed", - "total": 3, - "completed": 3, - "success": 2, - "failed": 1, - "skipped": 0, - "cancelled": false, - "current_index": 0, - "finished": true, - "task_uuids": [ - "03c182b4-d5d3-4939-b2a0-eda844c402d9", - "224f2a9f-c0f3-4d97-8e92-4c2e772a675b", - "6c4f0e18-47b1-473a-9cc5-83ef09e33ff8" - ] - } - }, - "otp_timeout_backoff": { - "service_id": 4, - "states": [ - { - "failures": 1, - "delay_seconds": 30, - "opened_until": 1774308959.612146, - "retry_after": null, - "last_error": "模拟 OTP 超时 #1" - }, - { - "failures": 2, - "delay_seconds": 60, - "opened_until": 1774308989.7684338, - "retry_after": null, - "last_error": "模拟 OTP 超时 #2" - }, - { - "failures": 3, - "delay_seconds": 3600, - "opened_until": 1774312529.923651, - "retry_after": null, - "last_error": "模拟 OTP 超时 #3" - } - ] - } - } - }, - "outlook_second_account": { - "email": "mock-outlook-a8f4da41@example.test", - "refresh_token": "new-second" - } - } -} \ No newline at end of file diff --git a/tests_runtime/runtime_recovery_report_1774308869.json b/tests_runtime/runtime_recovery_report_1774308869.json deleted file mode 100644 index 88d2fe4..0000000 --- a/tests_runtime/runtime_recovery_report_1774308869.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "mode": "verify-recovery", - "base_url": "http://127.0.0.1:15555", - "db_path": "/Volumes/Work/code/codex-manager/tests_runtime/e2e_runtime_1774308869.db", - "state": { - "stale_task_uuid": "stale-e738842e-74d8-400d-859e-1b283eab1a95", - "db_path": "/Volumes/Work/code/codex-manager/tests_runtime/e2e_runtime_1774308869.db", - "prepared_at": "2026-03-24 07:35:40" - }, - "health": { - "status_code": 200, - "body": { - "total": 9, - "tasks": [ - { - "id": 9, - "task_uuid": "stale-e738842e-74d8-400d-859e-1b283eab1a95", - "status": "failed", - "email_service_id": null, - "proxy": null, - "logs": "[00:00:00] stale task\n[系统] 服务启动时检测到未完成的历史任务,已标记失败,请重新发起。", - "result": null, - "error_message": "服务启动时检测到未完成的历史任务,已标记失败,请重新发起。", - "created_at": "2026-03-24T07:35:40", - "started_at": "2026-03-24T07:35:40", - "completed_at": "2026-03-23T23:35:57.292019" - } - ] - } - }, - "recovery": { - "task_uuid": "stale-e738842e-74d8-400d-859e-1b283eab1a95", - "status": "failed", - "error_message": "服务启动时检测到未完成的历史任务,已标记失败,请重新发起。", - "logs": "[00:00:00] stale task\n[系统] 服务启动时检测到未完成的历史任务,已标记失败,请重新发起。", - "completed_at": "2026-03-23 23:35:57.292019" - } -} \ No newline at end of file diff --git a/tests_runtime/runtime_recovery_state_1774308869.json b/tests_runtime/runtime_recovery_state_1774308869.json deleted file mode 100644 index 78643e5..0000000 --- a/tests_runtime/runtime_recovery_state_1774308869.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "stale_task_uuid": "stale-e738842e-74d8-400d-859e-1b283eab1a95", - "db_path": "/Volumes/Work/code/codex-manager/tests_runtime/e2e_runtime_1774308869.db", - "prepared_at": "2026-03-24 07:35:40" -} \ No newline at end of file