mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-05-06 20:02:51 +08:00
feat(email): add configurable resend limits for non-OpenAI sender emails
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
# 全局配置实例
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
# 等待一段时间再检查
|
||||
|
||||
@@ -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
|
||||
|
||||
# 提取验证码
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
|
||||
# 等待下次轮询
|
||||
|
||||
@@ -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, # 对于 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} 页`;
|
||||
}
|
||||
|
||||
@@ -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 导入
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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">×</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 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
45
tests/test_mail_openai_detection.py
Normal file
45
tests/test_mail_openai_detection.py
Normal 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
|
||||
|
||||
127
tests/test_outlook_service_config_and_health.py
Normal file
127
tests/test_outlook_service_config_and_health.py
Normal 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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user