mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-09-08 17:08:11 +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,
|
category=SettingCategory.EMAIL,
|
||||||
description="收件箱未找到验证码时,最多重新发送验证码的次数"
|
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 配置
|
||||||
"outlook_provider_priority": SettingDefinition(
|
"outlook_provider_priority": SettingDefinition(
|
||||||
@@ -390,6 +396,12 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = {
|
|||||||
category=SettingCategory.EMAIL,
|
category=SettingCategory.EMAIL,
|
||||||
description="Outlook OAuth 默认 Client ID"
|
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_timeout": int,
|
||||||
"email_code_poll_interval": int,
|
"email_code_poll_interval": int,
|
||||||
"email_code_resend_max_retries": int,
|
"email_code_resend_max_retries": int,
|
||||||
|
"email_code_non_openai_sender_resend_max_retries": int,
|
||||||
"outlook_provider_priority": list,
|
"outlook_provider_priority": list,
|
||||||
"outlook_health_failure_threshold": int,
|
"outlook_health_failure_threshold": int,
|
||||||
"outlook_health_disable_duration": int,
|
"outlook_health_disable_duration": int,
|
||||||
|
"outlook_require_recipient_match": bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 需要作为 SecretStr 处理的字段
|
# 需要作为 SecretStr 处理的字段
|
||||||
@@ -717,12 +731,14 @@ class Settings(BaseModel):
|
|||||||
email_code_timeout: int = 120
|
email_code_timeout: int = 120
|
||||||
email_code_poll_interval: int = 3
|
email_code_poll_interval: int = 3
|
||||||
email_code_resend_max_retries: int = 2
|
email_code_resend_max_retries: int = 2
|
||||||
|
email_code_non_openai_sender_resend_max_retries: int = 1
|
||||||
|
|
||||||
# Outlook 配置
|
# Outlook 配置
|
||||||
outlook_provider_priority: List[str] = ["imap_old", "imap_new", "graph_api"]
|
outlook_provider_priority: List[str] = ["imap_old", "imap_new", "graph_api"]
|
||||||
outlook_health_failure_threshold: int = 5
|
outlook_health_failure_threshold: int = 5
|
||||||
outlook_health_disable_duration: int = 60
|
outlook_health_disable_duration: int = 60
|
||||||
outlook_default_client_id: str = "24d9a0ed-8787-4584-883c-2fd79308940a"
|
outlook_default_client_id: str = "24d9a0ed-8787-4584-883c-2fd79308940a"
|
||||||
|
outlook_require_recipient_match: bool = True
|
||||||
|
|
||||||
|
|
||||||
# 全局配置实例
|
# 全局配置实例
|
||||||
|
|||||||
+62
-9
@@ -19,7 +19,11 @@ from curl_cffi import requests as cffi_requests
|
|||||||
from .openai.oauth import OAuthManager, OAuthStart
|
from .openai.oauth import OAuthManager, OAuthStart
|
||||||
from .http_client import OpenAIHTTPClient, HTTPClientError
|
from .http_client import OpenAIHTTPClient, HTTPClientError
|
||||||
from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType
|
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 import crud
|
||||||
from ..database.session import get_db
|
from ..database.session import get_db
|
||||||
from ..config.constants import (
|
from ..config.constants import (
|
||||||
@@ -712,6 +716,21 @@ class RegistrationEngine:
|
|||||||
return None, phase_result
|
return None, phase_result
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
self._log(f"获取验证码失败: {e}", "error")
|
||||||
phase_result = self._record_phase_result(
|
phase_result = self._record_phase_result(
|
||||||
PhaseResult(
|
PhaseResult(
|
||||||
@@ -1541,21 +1560,55 @@ class RegistrationEngine:
|
|||||||
self._log("10. 等待验证码...")
|
self._log("10. 等待验证码...")
|
||||||
self._emit_status("otp_secondary", "等待验证码邮件", step_index=10)
|
self._emit_status("otp_secondary", "等待验证码邮件", step_index=10)
|
||||||
otp_phase_started_at = time.time()
|
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
|
code, otp_phase = None, None
|
||||||
for _resend_attempt in range(_resend_max + 1):
|
while True:
|
||||||
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
|
|
||||||
code, otp_phase = self._phase_otp_secondary(
|
code, otp_phase = self._phase_otp_secondary(
|
||||||
PhaseContext(otp_sent_at=self._otp_sent_at),
|
PhaseContext(otp_sent_at=self._otp_sent_at),
|
||||||
started_at=otp_phase_started_at,
|
started_at=otp_phase_started_at,
|
||||||
)
|
)
|
||||||
if code:
|
if code:
|
||||||
break
|
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()
|
otp_phase_started_at = time.time()
|
||||||
if not code:
|
if not code:
|
||||||
result.error_message = (
|
result.error_message = (
|
||||||
|
|||||||
+46
-2
@@ -12,7 +12,7 @@ from datetime import datetime
|
|||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from enum import Enum
|
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
|
from ..config.settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ def get_email_code_settings() -> dict:
|
|||||||
}
|
}
|
||||||
EMAIL_PROVIDER_BACKOFF_MAX_SECONDS = 3600
|
EMAIL_PROVIDER_BACKOFF_MAX_SECONDS = 3600
|
||||||
OTP_TIMEOUT_ERROR_PREFIX = "OTP_TIMEOUT"
|
OTP_TIMEOUT_ERROR_PREFIX = "OTP_TIMEOUT"
|
||||||
|
OTP_NO_OPENAI_SENDER_ERROR = "OTP_NO_OPENAI_SENDER"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -130,6 +131,14 @@ class OTPTimeoutEmailServiceError(EmailServiceError):
|
|||||||
self.error_code = error_code
|
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):
|
class EmailServiceStatus(Enum):
|
||||||
"""邮箱服务状态"""
|
"""邮箱服务状态"""
|
||||||
HEALTHY = "healthy"
|
HEALTHY = "healthy"
|
||||||
@@ -312,6 +321,42 @@ class BaseEmailService(abc.ABC):
|
|||||||
|
|
||||||
return None
|
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:
|
def _get_used_verification_codes(self, email: str) -> set:
|
||||||
"""获取邮箱对应的已使用验证码集合。"""
|
"""获取邮箱对应的已使用验证码集合。"""
|
||||||
key = str(email or "").strip().lower()
|
key = str(email or "").strip().lower()
|
||||||
@@ -470,7 +515,6 @@ class BaseEmailService(abc.ABC):
|
|||||||
邮件信息字典,如果超时返回 None
|
邮件信息字典,如果超时返回 None
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
last_email_id = None
|
last_email_id = None
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import time
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
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 ..config.constants import OTP_CODE_PATTERN
|
||||||
from ..core.http_client import HTTPClient, RequestConfig
|
from ..core.http_client import HTTPClient, RequestConfig
|
||||||
|
|
||||||
@@ -256,6 +256,17 @@ class CloudMailService(BaseEmailService):
|
|||||||
time.sleep(poll_interval)
|
time.sleep(poll_interval)
|
||||||
continue
|
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:
|
for mail in mails:
|
||||||
msg_timestamp = self._get_received_timestamp(mail)
|
msg_timestamp = self._get_received_timestamp(mail)
|
||||||
if otp_sent_at is not None:
|
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
|
part for part in [sender, sender_name, subject, text_body, content] if part
|
||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
if "openai" not in search_text.lower():
|
if not self._is_openai_candidate_message(sender, sender_name, subject, text_body, content):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
code = self._extract_otp_from_text(search_text, pattern)
|
code = self._extract_otp_from_text(search_text, pattern)
|
||||||
@@ -287,6 +298,8 @@ class CloudMailService(BaseEmailService):
|
|||||||
logger.info(f"从 Cloud Mail 邮箱 {email} 找到验证码: {code}")
|
logger.info(f"从 Cloud Mail 邮箱 {email} 找到验证码: {code}")
|
||||||
return code
|
return code
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if isinstance(e, OTPNoOpenAISenderEmailServiceError):
|
||||||
|
raise
|
||||||
logger.debug(f"检查 Cloud Mail 邮件时出错: {e}")
|
logger.debug(f"检查 Cloud Mail 邮件时出错: {e}")
|
||||||
|
|
||||||
time.sleep(poll_interval)
|
time.sleep(poll_interval)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from datetime import datetime, timezone
|
|||||||
from html import unescape
|
from html import unescape
|
||||||
from typing import Any, Dict, List, Optional
|
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 ..config.constants import OTP_CODE_PATTERN
|
||||||
from ..core.http_client import HTTPClient, RequestConfig
|
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,
|
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:
|
for message in ordered_messages:
|
||||||
message_id = str(message.get("id") or "").strip()
|
message_id = str(message.get("id") or "").strip()
|
||||||
if not message_id or message_id in seen_message_ids:
|
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)
|
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
|
continue
|
||||||
|
|
||||||
match = re.search(pattern, content)
|
match = re.search(pattern, content)
|
||||||
@@ -306,6 +320,8 @@ class DuckMailService(BaseEmailService):
|
|||||||
self.update_status(True)
|
self.update_status(True)
|
||||||
return code
|
return code
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if isinstance(e, OTPNoOpenAISenderEmailServiceError):
|
||||||
|
raise
|
||||||
logger.debug(f"DuckMail 轮询验证码失败: {e}")
|
logger.debug(f"DuckMail 轮询验证码失败: {e}")
|
||||||
|
|
||||||
time.sleep(poll_interval)
|
time.sleep(poll_interval)
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ Freemail 邮箱服务实现
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import random
|
|
||||||
import string
|
|
||||||
from typing import Optional, Dict, Any, List
|
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 ..core.http_client import HTTPClient, RequestConfig
|
||||||
from ..config.constants import OTP_CODE_PATTERN
|
from ..config.constants import OTP_CODE_PATTERN
|
||||||
|
|
||||||
@@ -232,6 +230,17 @@ class FreemailService(BaseEmailService):
|
|||||||
) if isinstance(item, dict) else None,
|
) 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:
|
for mail in ordered_mails:
|
||||||
mail_id = mail.get("id")
|
mail_id = mail.get("id")
|
||||||
if not mail_id or mail_id in seen_mail_ids:
|
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}"
|
content = f"{sender}\n{subject}\n{preview}"
|
||||||
|
|
||||||
if "openai" not in content.lower():
|
if not self._is_openai_candidate_message(sender, subject, preview):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
code = self._extract_otp_from_text(content, pattern)
|
code = self._extract_otp_from_text(content, pattern)
|
||||||
@@ -286,6 +295,8 @@ class FreemailService(BaseEmailService):
|
|||||||
return v_code
|
return v_code
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if isinstance(e, OTPNoOpenAISenderEmailServiceError):
|
||||||
|
raise
|
||||||
logger.debug(f"检查 Freemail 邮件时出错: {e}")
|
logger.debug(f"检查 Freemail 邮件时出错: {e}")
|
||||||
|
|
||||||
time.sleep(poll_interval)
|
time.sleep(poll_interval)
|
||||||
|
|||||||
+20
-15
@@ -5,17 +5,16 @@ IMAP 邮箱服务
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import imaplib
|
import imaplib
|
||||||
import email
|
import email as py_email
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from email.header import decode_header
|
from email.header import decode_header
|
||||||
from typing import Any, Dict, Optional
|
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 (
|
from ..config.constants import (
|
||||||
EmailServiceType,
|
EmailServiceType,
|
||||||
OPENAI_EMAIL_SENDERS,
|
|
||||||
OTP_CODE_SEMANTIC_PATTERN,
|
OTP_CODE_SEMANTIC_PATTERN,
|
||||||
OTP_CODE_PATTERN,
|
OTP_CODE_PATTERN,
|
||||||
)
|
)
|
||||||
@@ -85,15 +84,7 @@ class ImapMailService(BaseEmailService):
|
|||||||
|
|
||||||
def _is_openai_sender(self, from_addr: str) -> bool:
|
def _is_openai_sender(self, from_addr: str) -> bool:
|
||||||
"""判断发件人是否为 OpenAI"""
|
"""判断发件人是否为 OpenAI"""
|
||||||
from_lower = from_addr.lower()
|
return self._is_openai_sender_value(from_addr)
|
||||||
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
|
|
||||||
|
|
||||||
def _extract_otp(self, text: str) -> Optional[str]:
|
def _extract_otp(self, text: str) -> Optional[str]:
|
||||||
"""从文本中提取 6 位验证码,优先语义匹配,回退简单匹配"""
|
"""从文本中提取 6 位验证码,优先语义匹配,回退简单匹配"""
|
||||||
@@ -126,7 +117,7 @@ class ImapMailService(BaseEmailService):
|
|||||||
poll_interval = get_email_code_settings()["poll_interval"]
|
poll_interval = get_email_code_settings()["poll_interval"]
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
seen_ids: set = set()
|
seen_ids: set = set()
|
||||||
mail = None
|
mail: Optional[imaplib.IMAP4] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mail = self._connect()
|
mail = self._connect()
|
||||||
@@ -141,24 +132,32 @@ class ImapMailService(BaseEmailService):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
msg_ids = data[0].split()
|
msg_ids = data[0].split()
|
||||||
|
seen_any_message = False
|
||||||
|
found_openai_sender = False
|
||||||
for msg_id in reversed(msg_ids): # 最新的优先
|
for msg_id in reversed(msg_ids): # 最新的优先
|
||||||
id_str = msg_id.decode()
|
id_str = msg_id.decode()
|
||||||
if id_str in seen_ids:
|
if id_str in seen_ids:
|
||||||
continue
|
continue
|
||||||
seen_ids.add(id_str)
|
seen_ids.add(id_str)
|
||||||
|
seen_any_message = True
|
||||||
|
|
||||||
# 获取邮件
|
# 获取邮件
|
||||||
status, msg_data = mail.fetch(msg_id, "(RFC822)")
|
status, msg_data = mail.fetch(msg_id, "(RFC822)")
|
||||||
if status != "OK" or not msg_data:
|
if status != "OK" or not msg_data:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
raw = msg_data[0][1]
|
first_part = msg_data[0]
|
||||||
msg = email.message_from_bytes(raw)
|
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", ""))
|
from_addr = self._decode_str(msg.get("From", ""))
|
||||||
if not self._is_openai_sender(from_addr):
|
if not self._is_openai_sender(from_addr):
|
||||||
continue
|
continue
|
||||||
|
found_openai_sender = True
|
||||||
|
|
||||||
# 提取验证码
|
# 提取验证码
|
||||||
body = self._get_text_body(msg)
|
body = self._get_text_body(msg)
|
||||||
@@ -170,10 +169,14 @@ class ImapMailService(BaseEmailService):
|
|||||||
logger.info(f"IMAP 获取验证码成功: {code}")
|
logger.info(f"IMAP 获取验证码成功: {code}")
|
||||||
return code
|
return code
|
||||||
|
|
||||||
|
if seen_any_message and not found_openai_sender:
|
||||||
|
raise OTPNoOpenAISenderEmailServiceError()
|
||||||
|
|
||||||
except imaplib.IMAP4.error as e:
|
except imaplib.IMAP4.error as e:
|
||||||
logger.debug(f"IMAP 搜索邮件失败: {e}")
|
logger.debug(f"IMAP 搜索邮件失败: {e}")
|
||||||
# 尝试重新连接
|
# 尝试重新连接
|
||||||
try:
|
try:
|
||||||
|
if mail is not None:
|
||||||
mail.select("INBOX")
|
mail.select("INBOX")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -181,6 +184,8 @@ class ImapMailService(BaseEmailService):
|
|||||||
time.sleep(poll_interval)
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if isinstance(e, OTPNoOpenAISenderEmailServiceError):
|
||||||
|
raise
|
||||||
logger.warning(f"IMAP 连接/轮询失败: {e}")
|
logger.warning(f"IMAP 连接/轮询失败: {e}")
|
||||||
self.update_status(False, str(e))
|
self.update_status(False, str(e))
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import logging
|
|||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from urllib.parse import urljoin
|
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 ..core.http_client import HTTPClient, RequestConfig
|
||||||
from ..config.constants import OTP_CODE_PATTERN
|
from ..config.constants import OTP_CODE_PATTERN
|
||||||
|
|
||||||
@@ -327,6 +327,13 @@ class MeoMailEmailService(BaseEmailService):
|
|||||||
) if isinstance(item, dict) else None,
|
) 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:
|
for message in ordered_messages:
|
||||||
message_id = message.get("id")
|
message_id = message.get("id")
|
||||||
if not message_id or message_id in seen_message_ids:
|
if not message_id or message_id in seen_message_ids:
|
||||||
@@ -353,7 +360,7 @@ class MeoMailEmailService(BaseEmailService):
|
|||||||
content = f"{sender} {subject} {message_content}"
|
content = f"{sender} {subject} {message_content}"
|
||||||
|
|
||||||
# 检查是否是 OpenAI 邮件
|
# 检查是否是 OpenAI 邮件
|
||||||
if "openai" not in sender and "openai" not in content.lower():
|
if not self._is_openai_candidate_message(sender, subject, message_content):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 提取验证码 过滤掉邮箱
|
# 提取验证码 过滤掉邮箱
|
||||||
@@ -368,6 +375,8 @@ class MeoMailEmailService(BaseEmailService):
|
|||||||
return code
|
return code
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if isinstance(e, OTPNoOpenAISenderEmailServiceError):
|
||||||
|
raise
|
||||||
logger.debug(f"检查邮件时出错: {e}")
|
logger.debug(f"检查邮件时出错: {e}")
|
||||||
|
|
||||||
# 等待一段时间再检查
|
# 等待一段时间再检查
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List
|
||||||
|
|
||||||
from ...config.constants import (
|
from ...config.constants import (
|
||||||
OTP_CODE_SIMPLE_PATTERN,
|
OTP_CODE_SIMPLE_PATTERN,
|
||||||
@@ -33,6 +33,7 @@ class EmailParser:
|
|||||||
self,
|
self,
|
||||||
email: EmailMessage,
|
email: EmailMessage,
|
||||||
target_email: Optional[str] = None,
|
target_email: Optional[str] = None,
|
||||||
|
require_recipient_match: bool = True,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
判断是否为 OpenAI 验证邮件
|
判断是否为 OpenAI 验证邮件
|
||||||
@@ -60,10 +61,32 @@ class EmailParser:
|
|||||||
logger.debug(f"邮件未包含验证关键词: {subject[:50]}")
|
logger.debug(f"邮件未包含验证关键词: {subject[:50]}")
|
||||||
return False
|
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]}")
|
logger.debug(f"识别为 OpenAI 验证邮件: {subject[:50]}")
|
||||||
return True
|
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(
|
def extract_verification_code(
|
||||||
self,
|
self,
|
||||||
email: EmailMessage,
|
email: EmailMessage,
|
||||||
@@ -123,11 +146,20 @@ class EmailParser:
|
|||||||
return match.group(1)
|
return match.group(1)
|
||||||
return None
|
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(
|
def find_verification_code_in_emails(
|
||||||
self,
|
self,
|
||||||
emails: List[EmailMessage],
|
emails: List[EmailMessage],
|
||||||
target_email: Optional[str] = None,
|
target_email: Optional[str] = None,
|
||||||
min_timestamp: int = 0,
|
min_timestamp: int = 0,
|
||||||
|
require_recipient_match: bool = True,
|
||||||
used_codes: Optional[set] = None,
|
used_codes: Optional[set] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
@@ -152,7 +184,11 @@ class EmailParser:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 检查是否是 OpenAI 验证邮件
|
# 检查是否是 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
|
continue
|
||||||
|
|
||||||
# 提取验证码
|
# 提取验证码
|
||||||
|
|||||||
@@ -4,12 +4,10 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
from datetime import datetime
|
||||||
from datetime import datetime, timedelta
|
from typing import Dict, List, Optional, Any, Tuple
|
||||||
from typing import Dict, List, Optional, Any
|
|
||||||
|
|
||||||
from .base import ProviderType, ProviderHealth, ProviderStatus
|
from .base import ProviderType, ProviderHealth, ProviderStatus
|
||||||
from .providers.base import OutlookProvider
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -39,45 +37,52 @@ class HealthChecker:
|
|||||||
self.disable_duration = disable_duration
|
self.disable_duration = disable_duration
|
||||||
self.recovery_check_interval = recovery_check_interval
|
self.recovery_check_interval = recovery_check_interval
|
||||||
|
|
||||||
# 提供者健康状态: ProviderType -> ProviderHealth
|
# 提供者健康状态: (account_email, provider_type) -> ProviderHealth
|
||||||
self._health_status: Dict[ProviderType, ProviderHealth] = {}
|
self._health_status: Dict[Tuple[str, ProviderType], ProviderHealth] = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
# 初始化所有提供者的健康状态
|
@staticmethod
|
||||||
for provider_type in ProviderType:
|
def _normalize_account_email(account_email: Optional[str]) -> str:
|
||||||
self._health_status[provider_type] = ProviderHealth(
|
return (account_email or "__global__").strip().lower()
|
||||||
provider_type=provider_type
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
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:
|
with self._lock:
|
||||||
health = self._health_status.get(provider_type)
|
health = self._ensure_health(provider_type, account_email)
|
||||||
if health:
|
|
||||||
health.record_success()
|
health.record_success()
|
||||||
logger.debug(f"{provider_type.value} 记录成功")
|
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:
|
with self._lock:
|
||||||
health = self._health_status.get(provider_type)
|
health = self._ensure_health(provider_type, account_email)
|
||||||
if health:
|
|
||||||
health.record_failure(error)
|
health.record_failure(error)
|
||||||
|
|
||||||
# 检查是否需要禁用
|
# 检查是否需要禁用
|
||||||
if health.should_disable(self.failure_threshold):
|
if health.should_disable(self.failure_threshold):
|
||||||
health.disable(self.disable_duration)
|
health.disable(self.disable_duration)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"{provider_type.value} 已禁用 {self.disable_duration} 秒,"
|
f"{provider_type.value} 已禁用 {self.disable_duration} 秒 "
|
||||||
f"原因: {error}"
|
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:
|
Returns:
|
||||||
是否可用
|
是否可用
|
||||||
"""
|
"""
|
||||||
health = self.get_health(provider_type)
|
health = self.get_health(provider_type, account_email)
|
||||||
|
|
||||||
# 检查是否被禁用
|
# 检查是否被禁用
|
||||||
if health.is_disabled():
|
if health.is_disabled():
|
||||||
remaining = (health.disabled_until - datetime.now()).total_seconds()
|
remaining = (health.disabled_until - datetime.now()).total_seconds()
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{provider_type.value} 已被禁用,剩余 {int(remaining)} 秒"
|
f"{provider_type.value} 已被禁用,剩余 {int(remaining)} 秒 "
|
||||||
|
f"({self._normalize_account_email(account_email)})"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -102,6 +108,7 @@ class HealthChecker:
|
|||||||
def get_available_providers(
|
def get_available_providers(
|
||||||
self,
|
self,
|
||||||
priority_order: Optional[List[ProviderType]] = None,
|
priority_order: Optional[List[ProviderType]] = None,
|
||||||
|
account_email: Optional[str] = None,
|
||||||
) -> List[ProviderType]:
|
) -> List[ProviderType]:
|
||||||
"""
|
"""
|
||||||
获取可用的提供者列表
|
获取可用的提供者列表
|
||||||
@@ -121,7 +128,7 @@ class HealthChecker:
|
|||||||
|
|
||||||
available = []
|
available = []
|
||||||
for provider_type in priority_order:
|
for provider_type in priority_order:
|
||||||
if self.is_available(provider_type):
|
if self.is_available(provider_type, account_email):
|
||||||
available.append(provider_type)
|
available.append(provider_type)
|
||||||
|
|
||||||
return available
|
return available
|
||||||
@@ -129,6 +136,7 @@ class HealthChecker:
|
|||||||
def get_next_available_provider(
|
def get_next_available_provider(
|
||||||
self,
|
self,
|
||||||
priority_order: Optional[List[ProviderType]] = None,
|
priority_order: Optional[List[ProviderType]] = None,
|
||||||
|
account_email: Optional[str] = None,
|
||||||
) -> Optional[ProviderType]:
|
) -> Optional[ProviderType]:
|
||||||
"""
|
"""
|
||||||
获取下一个可用的提供者
|
获取下一个可用的提供者
|
||||||
@@ -139,10 +147,10 @@ class HealthChecker:
|
|||||||
Returns:
|
Returns:
|
||||||
可用的提供者类型,如果没有返回 None
|
可用的提供者类型,如果没有返回 None
|
||||||
"""
|
"""
|
||||||
available = self.get_available_providers(priority_order)
|
available = self.get_available_providers(priority_order, account_email)
|
||||||
return available[0] if available else None
|
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: 禁用时长(秒),默认使用配置值
|
duration: 禁用时长(秒),默认使用配置值
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
health = self._health_status.get(provider_type)
|
health = self._ensure_health(provider_type, account_email)
|
||||||
if health:
|
|
||||||
health.disable(duration or self.disable_duration)
|
health.disable(duration or self.disable_duration)
|
||||||
logger.warning(f"{provider_type.value} 已强制禁用")
|
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: 提供者类型
|
provider_type: 提供者类型
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
health = self._health_status.get(provider_type)
|
health = self._ensure_health(provider_type, account_email)
|
||||||
if health:
|
|
||||||
health.enable()
|
health.enable()
|
||||||
logger.info(f"{provider_type.value} 已启用")
|
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:
|
with self._lock:
|
||||||
return {
|
normalized_account = self._normalize_account_email(account_email) if account_email is not None else None
|
||||||
provider_type.value: health.to_dict()
|
grouped: Dict[str, Dict[str, Any]] = {}
|
||||||
for provider_type, health in self._health_status.items()
|
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):
|
def check_and_recover(self):
|
||||||
"""
|
"""
|
||||||
@@ -189,20 +201,17 @@ class HealthChecker:
|
|||||||
如果禁用时间已过,自动恢复提供者
|
如果禁用时间已过,自动恢复提供者
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
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.is_disabled():
|
||||||
# 检查是否可以恢复
|
# 检查是否可以恢复
|
||||||
if health.disabled_until and datetime.now() >= health.disabled_until:
|
if health.disabled_until and datetime.now() >= health.disabled_until:
|
||||||
health.enable()
|
health.enable()
|
||||||
logger.info(f"{provider_type.value} 已自动恢复")
|
logger.info(f"{provider_type.value} 已自动恢复 ({account_email})")
|
||||||
|
|
||||||
def reset_all(self):
|
def reset_all(self):
|
||||||
"""重置所有提供者的健康状态"""
|
"""重置所有提供者的健康状态"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
for provider_type in ProviderType:
|
self._health_status.clear()
|
||||||
self._health_status[provider_type] = ProviderHealth(
|
|
||||||
provider_type=provider_type
|
|
||||||
)
|
|
||||||
logger.info("已重置所有提供者的健康状态")
|
logger.info("已重置所有提供者的健康状态")
|
||||||
|
|
||||||
|
|
||||||
@@ -235,14 +244,14 @@ class FailoverManager:
|
|||||||
self._current_index = 0
|
self._current_index = 0
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def get_current_provider(self) -> Optional[ProviderType]:
|
def get_current_provider(self, account_email: Optional[str] = None) -> Optional[ProviderType]:
|
||||||
"""
|
"""
|
||||||
获取当前提供者
|
获取当前提供者
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
当前提供者类型,如果没有可用的返回 None
|
当前提供者类型,如果没有可用的返回 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:
|
if not available:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -252,14 +261,14 @@ class FailoverManager:
|
|||||||
return available[self._current_index]
|
return available[self._current_index]
|
||||||
return available[0]
|
return available[0]
|
||||||
|
|
||||||
def switch_to_next(self) -> Optional[ProviderType]:
|
def switch_to_next(self, account_email: Optional[str] = None) -> Optional[ProviderType]:
|
||||||
"""
|
"""
|
||||||
切换到下一个提供者
|
切换到下一个提供者
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
下一个提供者类型,如果没有可用的返回 None
|
下一个提供者类型,如果没有可用的返回 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:
|
if not available:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -269,22 +278,22 @@ class FailoverManager:
|
|||||||
logger.info(f"切换到提供者: {next_provider.value}")
|
logger.info(f"切换到提供者: {next_provider.value}")
|
||||||
return next_provider
|
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:
|
Args:
|
||||||
provider_type: 提供者类型
|
provider_type: 提供者类型
|
||||||
"""
|
"""
|
||||||
self.health_checker.record_success(provider_type)
|
self.health_checker.record_success(provider_type, account_email)
|
||||||
|
|
||||||
# 重置索引到成功的提供者
|
# 重置索引到成功的提供者
|
||||||
with self._lock:
|
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:
|
if provider_type in available:
|
||||||
self._current_index = available.index(provider_type)
|
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: 提供者类型
|
provider_type: 提供者类型
|
||||||
error: 错误信息
|
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:
|
Returns:
|
||||||
状态字典
|
状态字典
|
||||||
"""
|
"""
|
||||||
current = self.get_current_provider()
|
current = self.get_current_provider(account_email)
|
||||||
return {
|
return {
|
||||||
"current_provider": current.value if current else None,
|
"current_provider": current.value if current else None,
|
||||||
"priority_order": [p.value for p in self.priority_order],
|
"priority_order": [p.value for p in self.priority_order],
|
||||||
"available_providers": [
|
"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
|
import time
|
||||||
from typing import Optional, Dict, Any, List
|
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.constants import EmailServiceType as ServiceType
|
||||||
|
from ...config.settings import get_settings
|
||||||
from .account import OutlookAccount
|
from .account import OutlookAccount
|
||||||
from .base import ProviderType, EmailMessage
|
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 .health_checker import HealthChecker, FailoverManager
|
||||||
from .providers.base import OutlookProvider, ProviderConfig
|
from .providers.base import OutlookProvider, ProviderConfig
|
||||||
from .providers.imap_old import IMAPOldProvider
|
from .providers.imap_old import IMAPOldProvider
|
||||||
@@ -215,7 +216,7 @@ class OutlookService(BaseEmailService):
|
|||||||
# 按优先级尝试各提供者
|
# 按优先级尝试各提供者
|
||||||
for provider_type in priority:
|
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(
|
logger.debug(
|
||||||
f"[{account.email}] {provider_type.value} 不可用,跳过"
|
f"[{account.email}] {provider_type.value} 不可用,跳过"
|
||||||
)
|
)
|
||||||
@@ -230,7 +231,7 @@ class OutlookService(BaseEmailService):
|
|||||||
|
|
||||||
if emails:
|
if emails:
|
||||||
# 成功获取邮件
|
# 成功获取邮件
|
||||||
self.health_checker.record_success(provider_type)
|
self.health_checker.record_success(provider_type, account.email)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[{account.email}] {provider_type.value} 获取到 {len(emails)} 封邮件"
|
f"[{account.email}] {provider_type.value} 获取到 {len(emails)} 封邮件"
|
||||||
)
|
)
|
||||||
@@ -239,7 +240,7 @@ class OutlookService(BaseEmailService):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
errors.append(f"{provider_type.value}: {error_msg}")
|
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(
|
logger.warning(
|
||||||
f"[{account.email}] {provider_type.value} 获取邮件失败: {e}"
|
f"[{account.email}] {provider_type.value} 获取邮件失败: {e}"
|
||||||
)
|
)
|
||||||
@@ -322,6 +323,7 @@ class OutlookService(BaseEmailService):
|
|||||||
f"[{email}] 开始获取验证码,超时 {actual_timeout}s,"
|
f"[{email}] 开始获取验证码,超时 {actual_timeout}s,"
|
||||||
f"提供者优先级: {[p.value for p in self.provider_priority]}"
|
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:
|
if email not in self._used_codes:
|
||||||
@@ -353,11 +355,19 @@ class OutlookService(BaseEmailService):
|
|||||||
f"[{email}] 第 {poll_count} 次轮询获取到 {len(emails)} 封邮件"
|
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(
|
code = self.email_parser.find_verification_code_in_emails(
|
||||||
emails,
|
emails,
|
||||||
target_email=email,
|
target_email=email,
|
||||||
min_timestamp=min_timestamp,
|
min_timestamp=min_timestamp,
|
||||||
|
require_recipient_match=require_recipient_match,
|
||||||
used_codes=used_codes,
|
used_codes=used_codes,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -372,6 +382,8 @@ class OutlookService(BaseEmailService):
|
|||||||
return code
|
return code
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if isinstance(e, OTPNoOpenAISenderEmailServiceError):
|
||||||
|
raise
|
||||||
logger.warning(f"[{email}] 检查出错: {e}")
|
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 html import unescape
|
||||||
from typing import Optional, Dict, Any, List
|
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 ..core.http_client import HTTPClient, RequestConfig
|
||||||
from ..config.constants import OTP_CODE_PATTERN
|
from ..config.constants import OTP_CODE_PATTERN
|
||||||
|
|
||||||
@@ -95,10 +95,7 @@ class TempMailService(BaseEmailService):
|
|||||||
charset = part.get_content_charset() or "utf-8"
|
charset = part.get_content_charset() or "utf-8"
|
||||||
text = payload.decode(charset, errors="replace") if payload else ""
|
text = payload.decode(charset, errors="replace") if payload else ""
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
text = str(part.get_payload() or "")
|
||||||
text = part.get_content()
|
|
||||||
except Exception:
|
|
||||||
text = ""
|
|
||||||
|
|
||||||
if content_type == "text/html":
|
if content_type == "text/html":
|
||||||
text = re.sub(r"<[^>]+>", " ", text)
|
text = re.sub(r"<[^>]+>", " ", text)
|
||||||
@@ -108,9 +105,6 @@ class TempMailService(BaseEmailService):
|
|||||||
payload = message.get_payload(decode=True)
|
payload = message.get_payload(decode=True)
|
||||||
charset = message.get_content_charset() or "utf-8"
|
charset = message.get_content_charset() or "utf-8"
|
||||||
body = payload.decode(charset, errors="replace") if payload else ""
|
body = payload.decode(charset, errors="replace") if payload else ""
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
body = message.get_content()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
body = str(message.get_payload() or "")
|
body = str(message.get_payload() or "")
|
||||||
|
|
||||||
@@ -346,6 +340,17 @@ class TempMailService(BaseEmailService):
|
|||||||
) if isinstance(item, dict) else None,
|
) 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:
|
for mail in ordered_mails:
|
||||||
mail_id = mail.get("id")
|
mail_id = mail.get("id")
|
||||||
if not mail_id or mail_id in seen_mail_ids:
|
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()
|
content = f"{sender}\n{subject}\n{body_text}\n{raw_text}".strip()
|
||||||
|
|
||||||
# 只处理 OpenAI 邮件
|
# 只处理 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
|
continue
|
||||||
|
|
||||||
code = self._extract_otp_from_text(content, pattern)
|
code = self._extract_otp_from_text(content, pattern)
|
||||||
@@ -380,6 +385,8 @@ class TempMailService(BaseEmailService):
|
|||||||
return code
|
return code
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if isinstance(e, OTPNoOpenAISenderEmailServiceError):
|
||||||
|
raise
|
||||||
logger.debug(f"检查 TempMail 邮件时出错: {e}")
|
logger.debug(f"检查 TempMail 邮件时出错: {e}")
|
||||||
|
|
||||||
time.sleep(poll_interval)
|
time.sleep(poll_interval)
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ Tempmail.lol 邮箱服务实现
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Callable, Optional, Dict, Any, List
|
||||||
from datetime import datetime, timezone
|
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 ..core.http_client import HTTPClient, RequestConfig
|
||||||
from ..config.constants import OTP_CODE_PATTERN
|
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,
|
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:
|
for msg in ordered_emails:
|
||||||
if not isinstance(msg, dict):
|
if not isinstance(msg, dict):
|
||||||
continue
|
continue
|
||||||
@@ -276,7 +283,7 @@ class TempmailService(BaseEmailService):
|
|||||||
content = "\n".join([sender, subject, body, html])
|
content = "\n".join([sender, subject, body, html])
|
||||||
|
|
||||||
# 检查是否是 OpenAI 邮件
|
# 检查是否是 OpenAI 邮件
|
||||||
if "openai" not in sender and "openai" not in content.lower():
|
if not self._is_openai_candidate_message(sender, subject, body, html):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 提取验证码
|
# 提取验证码
|
||||||
@@ -290,6 +297,8 @@ class TempmailService(BaseEmailService):
|
|||||||
return code
|
return code
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if isinstance(e, OTPNoOpenAISenderEmailServiceError):
|
||||||
|
raise
|
||||||
logger.debug(f"检查邮件时出错: {e}")
|
logger.debug(f"检查邮件时出错: {e}")
|
||||||
|
|
||||||
# 等待一段时间再检查
|
# 等待一段时间再检查
|
||||||
@@ -370,7 +379,7 @@ class TempmailService(BaseEmailService):
|
|||||||
self,
|
self,
|
||||||
email: str,
|
email: str,
|
||||||
token: str,
|
token: str,
|
||||||
callback: callable = None,
|
callback: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||||
timeout: int = 120
|
timeout: int = 120
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -285,6 +285,12 @@ def _normalize_email_service_config(
|
|||||||
if service_type == EmailServiceType.MOE_MAIL:
|
if service_type == EmailServiceType.MOE_MAIL:
|
||||||
if 'domain' in normalized and 'default_domain' not in normalized:
|
if 'domain' in normalized and 'default_domain' not in normalized:
|
||||||
normalized['default_domain'] = normalized.pop('domain')
|
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):
|
elif service_type in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL):
|
||||||
if 'default_domain' in normalized and 'domain' not in normalized:
|
if 'default_domain' in normalized and 'domain' not in normalized:
|
||||||
normalized['domain'] = normalized.pop('default_domain')
|
normalized['domain'] = normalized.pop('default_domain')
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -108,6 +108,8 @@ async def get_all_settings():
|
|||||||
"email_code": {
|
"email_code": {
|
||||||
"timeout": settings.email_code_timeout,
|
"timeout": settings.email_code_timeout,
|
||||||
"poll_interval": settings.email_code_poll_interval,
|
"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 # 验证码等待超时(秒)
|
timeout: int = 120 # 验证码等待超时(秒)
|
||||||
poll_interval: int = 3 # 验证码轮询间隔(秒)
|
poll_interval: int = 3 # 验证码轮询间隔(秒)
|
||||||
resend_max_retries: int = 2 # 收件箱未找到验证码时最多重新发送次数
|
resend_max_retries: int = 2 # 收件箱未找到验证码时最多重新发送次数
|
||||||
|
non_openai_sender_resend_max_retries: int = 1 # 非 OpenAI 发件人导致的最多重新发送次数
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tempmail")
|
@router.get("/tempmail")
|
||||||
@@ -425,6 +428,7 @@ async def get_email_code_settings():
|
|||||||
"timeout": settings.email_code_timeout,
|
"timeout": settings.email_code_timeout,
|
||||||
"poll_interval": settings.email_code_poll_interval,
|
"poll_interval": settings.email_code_poll_interval,
|
||||||
"resend_max_retries": settings.email_code_resend_max_retries,
|
"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:
|
if request.resend_max_retries < 0 or request.resend_max_retries > 10:
|
||||||
raise HTTPException(status_code=400, detail="重发次数必须在 0-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(
|
update_settings(
|
||||||
email_code_timeout=request.timeout,
|
email_code_timeout=request.timeout,
|
||||||
email_code_poll_interval=request.poll_interval,
|
email_code_poll_interval=request.poll_interval,
|
||||||
email_code_resend_max_retries=request.resend_max_retries,
|
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": "验证码等待设置已更新"}
|
return {"success": True, "message": "验证码等待设置已更新"}
|
||||||
@@ -842,6 +849,10 @@ async def disable_proxy(proxy_id: int):
|
|||||||
class OutlookSettings(BaseModel):
|
class OutlookSettings(BaseModel):
|
||||||
"""Outlook 设置"""
|
"""Outlook 设置"""
|
||||||
default_client_id: Optional[str] = None
|
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")
|
@router.get("/outlook")
|
||||||
@@ -854,6 +865,7 @@ async def get_outlook_settings():
|
|||||||
"provider_priority": settings.outlook_provider_priority,
|
"provider_priority": settings.outlook_provider_priority,
|
||||||
"health_failure_threshold": settings.outlook_health_failure_threshold,
|
"health_failure_threshold": settings.outlook_health_failure_threshold,
|
||||||
"health_disable_duration": settings.outlook_health_disable_duration,
|
"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:
|
if request.default_client_id is not None:
|
||||||
update_dict["outlook_default_client_id"] = request.default_client_id
|
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:
|
if update_dict:
|
||||||
update_settings(**update_dict)
|
update_settings(**update_dict)
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ const elements = {
|
|||||||
exportBtn: document.getElementById('export-btn'),
|
exportBtn: document.getElementById('export-btn'),
|
||||||
exportMenu: document.getElementById('export-menu'),
|
exportMenu: document.getElementById('export-menu'),
|
||||||
selectAll: document.getElementById('select-all'),
|
selectAll: document.getElementById('select-all'),
|
||||||
|
firstPage: document.getElementById('first-page'),
|
||||||
prevPage: document.getElementById('prev-page'),
|
prevPage: document.getElementById('prev-page'),
|
||||||
nextPage: document.getElementById('next-page'),
|
nextPage: document.getElementById('next-page'),
|
||||||
|
lastPage: document.getElementById('last-page'),
|
||||||
pageInfo: document.getElementById('page-info'),
|
pageInfo: document.getElementById('page-info'),
|
||||||
detailModal: document.getElementById('detail-modal'),
|
detailModal: document.getElementById('detail-modal'),
|
||||||
modalBody: document.getElementById('modal-body'),
|
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', () => {
|
elements.prevPage.addEventListener('click', () => {
|
||||||
if (currentPage > 1 && !isLoading) {
|
if (currentPage > 1 && !isLoading) {
|
||||||
currentPage--;
|
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) => {
|
elements.exportBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -427,8 +444,10 @@ function togglePassword(element, password) {
|
|||||||
function updatePagination() {
|
function updatePagination() {
|
||||||
const totalPages = Math.max(1, Math.ceil(totalAccounts / pageSize));
|
const totalPages = Math.max(1, Math.ceil(totalAccounts / pageSize));
|
||||||
|
|
||||||
|
elements.firstPage.disabled = currentPage <= 1;
|
||||||
elements.prevPage.disabled = currentPage <= 1;
|
elements.prevPage.disabled = currentPage <= 1;
|
||||||
elements.nextPage.disabled = currentPage >= totalPages;
|
elements.nextPage.disabled = currentPage >= totalPages;
|
||||||
|
elements.lastPage.disabled = currentPage >= totalPages;
|
||||||
|
|
||||||
elements.pageInfo.textContent = `第 ${currentPage} 页 / 共 ${totalPages} 页`;
|
elements.pageInfo.textContent = `第 ${currentPage} 页 / 共 ${totalPages} 页`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ const elements = {
|
|||||||
tempmailStatus: document.getElementById('tempmail-status'),
|
tempmailStatus: document.getElementById('tempmail-status'),
|
||||||
totalEnabled: document.getElementById('total-enabled'),
|
totalEnabled: document.getElementById('total-enabled'),
|
||||||
|
|
||||||
// Outlook 导入
|
// Outlook 导入模态框
|
||||||
toggleOutlookImport: document.getElementById('toggle-outlook-import'),
|
addOutlookBtn: document.getElementById('add-outlook-btn'),
|
||||||
outlookImportBody: document.getElementById('outlook-import-body'),
|
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'),
|
outlookImportData: document.getElementById('outlook-import-data'),
|
||||||
outlookImportEnabled: document.getElementById('outlook-import-enabled'),
|
outlookImportEnabled: document.getElementById('outlook-import-enabled'),
|
||||||
outlookImportPriority: document.getElementById('outlook-import-priority'),
|
outlookImportPriority: document.getElementById('outlook-import-priority'),
|
||||||
@@ -96,11 +98,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// 事件监听
|
// 事件监听
|
||||||
function initEventListeners() {
|
function initEventListeners() {
|
||||||
// Outlook 导入展开/收起
|
// Outlook 添加邮箱按钮 → 打开模态框
|
||||||
elements.toggleOutlookImport.addEventListener('click', () => {
|
elements.addOutlookBtn.addEventListener('click', () => {
|
||||||
const isHidden = elements.outlookImportBody.style.display === 'none';
|
elements.outlookImportData.value = '';
|
||||||
elements.outlookImportBody.style.display = isHidden ? 'block' : 'none';
|
elements.importResult.style.display = 'none';
|
||||||
elements.toggleOutlookImport.textContent = isHidden ? '收起' : '展开';
|
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 导入
|
// Outlook 导入
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ async function loadSettings() {
|
|||||||
document.getElementById('email-code-timeout').value = data.email_code.timeout || 120;
|
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-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-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 设置
|
// 加载 Outlook 设置
|
||||||
@@ -565,16 +566,22 @@ async function handleSaveEmailCode(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resendMaxRetries = parseInt(document.getElementById('email-code-resend-max-retries').value);
|
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) {
|
if (resendMaxRetries < 0 || resendMaxRetries > 10) {
|
||||||
toast.error('重发次数必须在 0-10 之间');
|
toast.error('重发次数必须在 0-10 之间');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (nonOpenaiSenderResendMaxRetries < 0 || nonOpenaiSenderResendMaxRetries > 10) {
|
||||||
|
toast.error('非 OpenAI 发件人重发次数必须在 0-10 之间');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
poll_interval: pollInterval,
|
poll_interval: pollInterval,
|
||||||
resend_max_retries: resendMaxRetries
|
resend_max_retries: resendMaxRetries,
|
||||||
|
non_openai_sender_resend_max_retries: nonOpenaiSenderResendMaxRetries
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -198,6 +198,9 @@
|
|||||||
|
|
||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
<div class="pagination" id="pagination">
|
<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 class="btn btn-secondary btn-sm" id="prev-page" disabled>
|
||||||
← 上一页
|
← 上一页
|
||||||
</button>
|
</button>
|
||||||
@@ -205,6 +208,9 @@
|
|||||||
<button class="btn btn-secondary btn-sm" id="next-page">
|
<button class="btn btn-secondary btn-sm" id="next-page">
|
||||||
下一页 →
|
下一页 →
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="last-page">
|
||||||
|
末页 ⇥
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,45 +54,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- 自定义邮箱管理(含 MoeMail / TempMail / DuckMail) -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -133,8 +94,11 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>📧 Outlook 账户列表</h3>
|
<h3>📧 Outlook 账户列表</h3>
|
||||||
|
<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>
|
<button class="btn btn-danger btn-sm" id="batch-delete-outlook-btn" disabled>🗑️ 批量删除</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card-body" style="padding: 0;">
|
<div class="card-body" style="padding: 0;">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
@@ -556,7 +520,49 @@
|
|||||||
</div>
|
</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>
|
<script src="/static/js/email_services.js?v={{ static_version }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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">
|
<input type="number" id="email-code-resend-max-retries" name="resend_max_retries" value="2" min="0" max="10">
|
||||||
<span class="hint">收件箱未找到验证码时,最多重新触发发送的次数</span>
|
<span class="hint">收件箱未找到验证码时,最多重新触发发送的次数</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -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 (
|
from src.core.register import (
|
||||||
ERROR_OTP_TIMEOUT_SECONDARY,
|
ERROR_OTP_TIMEOUT_SECONDARY,
|
||||||
PhaseContext,
|
PhaseContext,
|
||||||
|
PhaseResult,
|
||||||
RegistrationEngine,
|
RegistrationEngine,
|
||||||
)
|
)
|
||||||
from src.services import EmailServiceType
|
from src.services import EmailServiceType
|
||||||
@@ -13,6 +14,10 @@ class DummySettings:
|
|||||||
openai_token_url = "https://token.example.test"
|
openai_token_url = "https://token.example.test"
|
||||||
openai_redirect_uri = "https://callback.example.test"
|
openai_redirect_uri = "https://callback.example.test"
|
||||||
openai_scope = "openid profile email"
|
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:
|
class FakeEmailService:
|
||||||
@@ -26,6 +31,12 @@ class FakeEmailService:
|
|||||||
return self.code
|
return self.code
|
||||||
|
|
||||||
|
|
||||||
|
class FastResendEmailService(FakeEmailService):
|
||||||
|
def get_verification_code(self, **kwargs):
|
||||||
|
self.calls.append(kwargs)
|
||||||
|
raise register_module.OTPNoOpenAISenderEmailServiceError()
|
||||||
|
|
||||||
|
|
||||||
class FakeCookies:
|
class FakeCookies:
|
||||||
def __init__(self, values):
|
def __init__(self, values):
|
||||||
self.values = values
|
self.values = values
|
||||||
@@ -56,11 +67,67 @@ class FakeResponse:
|
|||||||
return self._json_payload
|
return self._json_payload
|
||||||
|
|
||||||
|
|
||||||
def _build_engine(monkeypatch, email_service):
|
def _build_engine(monkeypatch, email_service, **setting_overrides):
|
||||||
monkeypatch.setattr(register_module, "get_settings", lambda: DummySettings())
|
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)
|
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):
|
def test_phase_otp_secondary_uses_remaining_budget_from_start_timestamp(monkeypatch):
|
||||||
email_service = FakeEmailService(code="654321")
|
email_service = FakeEmailService(code="654321")
|
||||||
engine = _build_engine(monkeypatch, email_service)
|
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
|
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):
|
def test_advance_login_authorization_sets_otp_anchor_before_password_submit(monkeypatch):
|
||||||
email_service = FakeEmailService(code=None)
|
email_service = FakeEmailService(code=None)
|
||||||
engine = _build_engine(monkeypatch, email_service)
|
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