feat(email): add configurable resend limits for non-OpenAI sender emails

This commit is contained in:
cnlimiter
2026-03-28 02:51:12 +08:00
parent b751d656ea
commit 51922ef2a6
30 changed files with 815 additions and 1263 deletions

View File

@@ -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
# 全局配置实例

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}")
# 等待一段时间再检查

View File

@@ -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 <user@example.com>" 形式
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
# 提取验证码

View File

@@ -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),
}

View File

@@ -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}")
# 等待下次轮询

View File

@@ -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 "<html" in text.lower():
text = re.sub(r"<[^>]+>", " ", 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, # 对于 Outlookservice_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: 未使用(对于 Outlookemail 就是标识)
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}sOTP发送时间: {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:
FalseOutlook 不支持删除账户)
"""
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

View File

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

View File

@@ -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]:
"""

View File

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

View File

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

View File

@@ -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}`;
}

View File

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

View File

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

View File

@@ -198,6 +198,9 @@
<!-- 分页 -->
<div class="pagination" id="pagination">
<button class="btn btn-secondary btn-sm" id="first-page" disabled>
⇤ 首页
</button>
<button class="btn btn-secondary btn-sm" id="prev-page" disabled>
← 上一页
</button>
@@ -205,6 +208,9 @@
<button class="btn btn-secondary btn-sm" id="next-page">
下一页 →
</button>
<button class="btn btn-secondary btn-sm" id="last-page">
末页 ⇥
</button>
</div>
</div>
</div>

View File

@@ -54,45 +54,6 @@
</div>
</div>
<!-- Outlook 管理 -->
<div class="card">
<div class="card-header">
<h3>📥 Outlook 批量导入</h3>
<button class="btn btn-ghost btn-sm" id="toggle-outlook-import">展开</button>
</div>
<div class="card-body" id="outlook-import-body" style="display: none;">
<div class="import-info">
<p><strong>支持格式:</strong></p>
<ul>
<li><code>邮箱----密码</code> (密码认证)</li>
<li><code>邮箱----密码----client_id----refresh_token</code> XOAUTH2 认证,推荐)</li>
</ul>
<p>每行一个账户,使用四个连字符(----)分隔字段。以 # 开头的行将被忽略。</p>
</div>
<div class="form-group">
<label for="outlook-import-data">批量导入数据</label>
<textarea id="outlook-import-data" rows="8" placeholder="example@outlook.com----password123&#10;test@outlook.com----password456----client_id----refresh_token"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>
<input type="checkbox" id="outlook-import-enabled" checked>
导入后启用
</label>
</div>
<div class="form-group">
<label for="outlook-import-priority">优先级</label>
<input type="number" id="outlook-import-priority" value="0" min="0">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" id="outlook-import-btn">📥 开始导入</button>
<button type="button" class="btn btn-secondary" id="clear-import-btn">清空</button>
</div>
<div id="import-result" style="display: none; margin-top: var(--spacing-md);"></div>
</div>
</div>
<!-- 自定义邮箱管理(含 MoeMail / TempMail / DuckMail -->
<div class="card">
<div class="card-header">
@@ -133,7 +94,10 @@
<div class="card">
<div class="card-header">
<h3>📧 Outlook 账户列表</h3>
<button class="btn btn-danger btn-sm" id="batch-delete-outlook-btn" disabled>🗑️ 批量删除</button>
<div style="display:flex;gap:8px;">
<button class="btn btn-primary btn-sm" id="add-outlook-btn"> 添加邮箱</button>
<button class="btn btn-danger btn-sm" id="batch-delete-outlook-btn" disabled>🗑️ 批量删除</button>
</div>
</div>
<div class="card-body" style="padding: 0;">
<div class="table-container">
@@ -556,7 +520,49 @@
</div>
<script src="/static/js/utils.js?v={{ static_version }}"></script>
<!-- Outlook 批量导入模态框 -->
<div class="modal" id="outlook-import-modal">
<div class="modal-content" style="max-width: 560px;">
<div class="modal-header">
<h3>📥 批量添加 Outlook 账户</h3>
<button class="modal-close" id="close-outlook-import-modal">&times;</button>
</div>
<div class="modal-body">
<div class="import-info" style="margin-bottom: var(--spacing-md);">
<p><strong>支持格式:</strong></p>
<ul>
<li><code>邮箱----密码</code> (密码认证)</li>
<li><code>邮箱----密码----client_id----refresh_token</code> XOAUTH2 认证,推荐)</li>
</ul>
<p style="color: var(--text-muted); font-size: 0.875rem;">每行一个账户,使用四个连字符(----)分隔字段。以 # 开头的行将被忽略。</p>
</div>
<div class="form-group">
<label for="outlook-import-data">批量导入数据</label>
<textarea id="outlook-import-data" rows="8" placeholder="example@outlook.com----password123&#10;test@outlook.com----password456----client_id----refresh_token"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>
<input type="checkbox" id="outlook-import-enabled" checked>
导入后启用
</label>
</div>
<div class="form-group">
<label for="outlook-import-priority">优先级</label>
<input type="number" id="outlook-import-priority" value="0" min="0">
</div>
</div>
<div id="import-result" style="display: none; margin-top: var(--spacing-md);"></div>
</div>
<div class="modal-footer" style="padding: 12px 20px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end;">
<button type="button" class="btn btn-primary" id="outlook-import-btn">📥 开始导入</button>
<button type="button" class="btn btn-secondary" id="clear-import-btn">清空</button>
<button type="button" class="btn btn-secondary" id="cancel-outlook-import-btn">关闭</button>
</div>
</div>
</div>
<script src="/static/js/utils.js?v={{ static_version }}"></script>
<script src="/static/js/email_services.js?v={{ static_version }}"></script>
</body>
</html>

View File

@@ -581,6 +581,12 @@ MyProxy|socks5://user:pass@host:port"></textarea>
<input type="number" id="email-code-resend-max-retries" name="resend_max_retries" value="2" min="0" max="10">
<span class="hint">收件箱未找到验证码时,最多重新触发发送的次数</span>
</div>
<div class="form-group">
<label for="email-code-non-openai-sender-resend-max-retries">非 OpenAI 发件人最多重发次数</label>
<input type="number" id="email-code-non-openai-sender-resend-max-retries" name="non_openai_sender_resend_max_retries" value="1" min="0" max="10">
<span class="hint">检测到疑似干扰邮件但发件人不是 OpenAI 时,单独允许重新触发发送的次数</span>
</div>
</div>
<div class="form-actions">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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