feat: codex-register with Sub2API增强 + Playwright引擎
Some checks are pending
Docker Image CI / build-and-push-image (push) Waiting to run
Some checks are pending
Docker Image CI / build-and-push-image (push) Waiting to run
This commit is contained in:
73
src/services/__init__.py
Normal file
73
src/services/__init__.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
邮箱服务模块
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
BaseEmailService,
|
||||
EmailServiceError,
|
||||
EmailServiceStatus,
|
||||
EmailServiceFactory,
|
||||
create_email_service,
|
||||
EmailServiceType
|
||||
)
|
||||
from .tempmail import TempmailService
|
||||
from .outlook import OutlookService
|
||||
from .moe_mail import MeoMailEmailService
|
||||
from .temp_mail import TempMailService
|
||||
from .duck_mail import DuckMailService
|
||||
from .freemail import FreemailService
|
||||
from .imap_mail import ImapMailService
|
||||
|
||||
# 注册服务
|
||||
EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService)
|
||||
EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService)
|
||||
EmailServiceFactory.register(EmailServiceType.MOE_MAIL, MeoMailEmailService)
|
||||
EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService)
|
||||
EmailServiceFactory.register(EmailServiceType.DUCK_MAIL, DuckMailService)
|
||||
EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService)
|
||||
EmailServiceFactory.register(EmailServiceType.IMAP_MAIL, ImapMailService)
|
||||
|
||||
# 导出 Outlook 模块的额外内容
|
||||
from .outlook.base import (
|
||||
ProviderType,
|
||||
EmailMessage,
|
||||
TokenInfo,
|
||||
ProviderHealth,
|
||||
ProviderStatus,
|
||||
)
|
||||
from .outlook.account import OutlookAccount
|
||||
from .outlook.providers import (
|
||||
OutlookProvider,
|
||||
IMAPOldProvider,
|
||||
IMAPNewProvider,
|
||||
GraphAPIProvider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 基类
|
||||
'BaseEmailService',
|
||||
'EmailServiceError',
|
||||
'EmailServiceStatus',
|
||||
'EmailServiceFactory',
|
||||
'create_email_service',
|
||||
'EmailServiceType',
|
||||
# 服务类
|
||||
'TempmailService',
|
||||
'OutlookService',
|
||||
'MeoMailEmailService',
|
||||
'TempMailService',
|
||||
'DuckMailService',
|
||||
'FreemailService',
|
||||
'ImapMailService',
|
||||
# Outlook 模块
|
||||
'ProviderType',
|
||||
'EmailMessage',
|
||||
'TokenInfo',
|
||||
'ProviderHealth',
|
||||
'ProviderStatus',
|
||||
'OutlookAccount',
|
||||
'OutlookProvider',
|
||||
'IMAPOldProvider',
|
||||
'IMAPNewProvider',
|
||||
'GraphAPIProvider',
|
||||
]
|
||||
386
src/services/base.py
Normal file
386
src/services/base.py
Normal file
@@ -0,0 +1,386 @@
|
||||
"""
|
||||
邮箱服务抽象基类
|
||||
所有邮箱服务实现的基类
|
||||
"""
|
||||
|
||||
import abc
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from enum import Enum
|
||||
|
||||
from ..config.constants import EmailServiceType
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailServiceError(Exception):
|
||||
"""邮箱服务异常"""
|
||||
pass
|
||||
|
||||
|
||||
class EmailServiceStatus(Enum):
|
||||
"""邮箱服务状态"""
|
||||
HEALTHY = "healthy"
|
||||
DEGRADED = "degraded"
|
||||
UNAVAILABLE = "unavailable"
|
||||
|
||||
|
||||
class BaseEmailService(abc.ABC):
|
||||
"""
|
||||
邮箱服务抽象基类
|
||||
|
||||
所有邮箱服务必须实现此接口
|
||||
"""
|
||||
|
||||
def __init__(self, service_type: EmailServiceType, name: str = None):
|
||||
"""
|
||||
初始化邮箱服务
|
||||
|
||||
Args:
|
||||
service_type: 服务类型
|
||||
name: 服务名称
|
||||
"""
|
||||
self.service_type = service_type
|
||||
self.name = name or f"{service_type.value}_service"
|
||||
self._status = EmailServiceStatus.HEALTHY
|
||||
self._last_error = None
|
||||
|
||||
@property
|
||||
def status(self) -> EmailServiceStatus:
|
||||
"""获取服务状态"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def last_error(self) -> Optional[str]:
|
||||
"""获取最后一次错误信息"""
|
||||
return self._last_error
|
||||
|
||||
@abc.abstractmethod
|
||||
def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
创建新邮箱地址
|
||||
|
||||
Args:
|
||||
config: 配置参数,如邮箱前缀、域名等
|
||||
|
||||
Returns:
|
||||
包含邮箱信息的字典,至少包含:
|
||||
- email: 邮箱地址
|
||||
- service_id: 邮箱服务中的 ID
|
||||
- token/credentials: 访问凭证(如果需要)
|
||||
|
||||
Raises:
|
||||
EmailServiceError: 创建失败
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_verification_code(
|
||||
self,
|
||||
email: str,
|
||||
email_id: str = None,
|
||||
timeout: int = 120,
|
||||
pattern: str = r"(?<!\d)(\d{6})(?!\d)",
|
||||
otp_sent_at: Optional[float] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
获取验证码
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
email_id: 邮箱服务中的 ID(如果需要)
|
||||
timeout: 超时时间(秒)
|
||||
pattern: 验证码正则表达式
|
||||
otp_sent_at: OTP 发送时间戳,用于过滤旧邮件
|
||||
|
||||
Returns:
|
||||
验证码字符串,如果超时或未找到返回 None
|
||||
|
||||
Raises:
|
||||
EmailServiceError: 服务错误
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
列出所有邮箱(如果服务支持)
|
||||
|
||||
Args:
|
||||
**kwargs: 其他参数
|
||||
|
||||
Returns:
|
||||
邮箱列表
|
||||
|
||||
Raises:
|
||||
EmailServiceError: 服务错误
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def delete_email(self, email_id: str) -> bool:
|
||||
"""
|
||||
删除邮箱
|
||||
|
||||
Args:
|
||||
email_id: 邮箱服务中的 ID
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
|
||||
Raises:
|
||||
EmailServiceError: 服务错误
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def check_health(self) -> bool:
|
||||
"""
|
||||
检查服务健康状态
|
||||
|
||||
Returns:
|
||||
服务是否健康
|
||||
|
||||
Note:
|
||||
此方法不应抛出异常,应捕获异常并返回 False
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_email_info(self, email_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取邮箱信息(可选实现)
|
||||
|
||||
Args:
|
||||
email_id: 邮箱服务中的 ID
|
||||
|
||||
Returns:
|
||||
邮箱信息字典,如果不存在返回 None
|
||||
"""
|
||||
# 默认实现:遍历列表查找
|
||||
for email_info in self.list_emails():
|
||||
if email_info.get("id") == email_id:
|
||||
return email_info
|
||||
return None
|
||||
|
||||
def wait_for_email(
|
||||
self,
|
||||
email: str,
|
||||
email_id: str = None,
|
||||
timeout: int = 120,
|
||||
check_interval: int = 3,
|
||||
expected_sender: str = None,
|
||||
expected_subject: str = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
等待并获取邮件(可选实现)
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
email_id: 邮箱服务中的 ID
|
||||
timeout: 超时时间(秒)
|
||||
check_interval: 检查间隔(秒)
|
||||
expected_sender: 期望的发件人(包含检查)
|
||||
expected_subject: 期望的主题(包含检查)
|
||||
|
||||
Returns:
|
||||
邮件信息字典,如果超时返回 None
|
||||
"""
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
start_time = time.time()
|
||||
last_email_id = None
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
emails = self.list_emails()
|
||||
for email_info in emails:
|
||||
email_data = email_info.get("email", {})
|
||||
current_email_id = email_info.get("id")
|
||||
|
||||
# 检查是否是新的邮件
|
||||
if last_email_id and current_email_id == last_email_id:
|
||||
continue
|
||||
|
||||
# 检查邮箱地址
|
||||
if email_data.get("address") != email:
|
||||
continue
|
||||
|
||||
# 获取邮件列表
|
||||
messages = self.get_email_messages(email_id or current_email_id)
|
||||
for message in messages:
|
||||
# 检查发件人
|
||||
if expected_sender and expected_sender not in message.get("from", ""):
|
||||
continue
|
||||
|
||||
# 检查主题
|
||||
if expected_subject and expected_subject not in message.get("subject", ""):
|
||||
continue
|
||||
|
||||
# 返回邮件信息
|
||||
return {
|
||||
"id": message.get("id"),
|
||||
"from": message.get("from"),
|
||||
"subject": message.get("subject"),
|
||||
"content": message.get("content"),
|
||||
"received_at": message.get("received_at"),
|
||||
"email_info": email_info
|
||||
}
|
||||
|
||||
# 更新最后检查的邮件 ID
|
||||
if messages:
|
||||
last_email_id = current_email_id
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"等待邮件时出错: {e}")
|
||||
|
||||
time.sleep(check_interval)
|
||||
|
||||
return None
|
||||
|
||||
def get_email_messages(self, email_id: str, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取邮箱中的邮件列表(可选实现)
|
||||
|
||||
Args:
|
||||
email_id: 邮箱服务中的 ID
|
||||
**kwargs: 其他参数
|
||||
|
||||
Returns:
|
||||
邮件列表
|
||||
|
||||
Note:
|
||||
这是可选方法,某些服务可能不支持
|
||||
"""
|
||||
raise NotImplementedError("此邮箱服务不支持获取邮件列表")
|
||||
|
||||
def get_message_content(self, email_id: str, message_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取邮件内容(可选实现)
|
||||
|
||||
Args:
|
||||
email_id: 邮箱服务中的 ID
|
||||
message_id: 邮件 ID
|
||||
|
||||
Returns:
|
||||
邮件内容字典
|
||||
|
||||
Note:
|
||||
这是可选方法,某些服务可能不支持
|
||||
"""
|
||||
raise NotImplementedError("此邮箱服务不支持获取邮件内容")
|
||||
|
||||
def update_status(self, success: bool, error: Exception = None):
|
||||
"""
|
||||
更新服务状态
|
||||
|
||||
Args:
|
||||
success: 操作是否成功
|
||||
error: 错误信息
|
||||
"""
|
||||
if success:
|
||||
self._status = EmailServiceStatus.HEALTHY
|
||||
self._last_error = None
|
||||
else:
|
||||
self._status = EmailServiceStatus.DEGRADED
|
||||
if error:
|
||||
self._last_error = str(error)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""字符串表示"""
|
||||
return f"{self.name} ({self.service_type.value})"
|
||||
|
||||
|
||||
class EmailServiceFactory:
|
||||
"""邮箱服务工厂"""
|
||||
|
||||
_registry: Dict[EmailServiceType, type] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, service_type: EmailServiceType, service_class: type):
|
||||
"""
|
||||
注册邮箱服务类
|
||||
|
||||
Args:
|
||||
service_type: 服务类型
|
||||
service_class: 服务类
|
||||
"""
|
||||
if not issubclass(service_class, BaseEmailService):
|
||||
raise TypeError(f"{service_class} 必须是 BaseEmailService 的子类")
|
||||
cls._registry[service_type] = service_class
|
||||
logger.info(f"注册邮箱服务: {service_type.value} -> {service_class.__name__}")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
service_type: EmailServiceType,
|
||||
config: Dict[str, Any],
|
||||
name: str = None
|
||||
) -> BaseEmailService:
|
||||
"""
|
||||
创建邮箱服务实例
|
||||
|
||||
Args:
|
||||
service_type: 服务类型
|
||||
config: 服务配置
|
||||
name: 服务名称
|
||||
|
||||
Returns:
|
||||
邮箱服务实例
|
||||
|
||||
Raises:
|
||||
ValueError: 服务类型未注册或配置无效
|
||||
"""
|
||||
if service_type not in cls._registry:
|
||||
raise ValueError(f"未注册的服务类型: {service_type.value}")
|
||||
|
||||
service_class = cls._registry[service_type]
|
||||
try:
|
||||
instance = service_class(config, name)
|
||||
return instance
|
||||
except Exception as e:
|
||||
raise ValueError(f"创建邮箱服务失败: {e}")
|
||||
|
||||
@classmethod
|
||||
def get_available_services(cls) -> List[EmailServiceType]:
|
||||
"""
|
||||
获取所有已注册的服务类型
|
||||
|
||||
Returns:
|
||||
已注册的服务类型列表
|
||||
"""
|
||||
return list(cls._registry.keys())
|
||||
|
||||
@classmethod
|
||||
def get_service_class(cls, service_type: EmailServiceType) -> Optional[type]:
|
||||
"""
|
||||
获取服务类
|
||||
|
||||
Args:
|
||||
service_type: 服务类型
|
||||
|
||||
Returns:
|
||||
服务类,如果未注册返回 None
|
||||
"""
|
||||
return cls._registry.get(service_type)
|
||||
|
||||
|
||||
# 简化的工厂函数
|
||||
def create_email_service(
|
||||
service_type: EmailServiceType,
|
||||
config: Dict[str, Any],
|
||||
name: str = None
|
||||
) -> BaseEmailService:
|
||||
"""
|
||||
创建邮箱服务(简化工厂函数)
|
||||
|
||||
Args:
|
||||
service_type: 服务类型
|
||||
config: 服务配置
|
||||
name: 服务名称
|
||||
|
||||
Returns:
|
||||
邮箱服务实例
|
||||
"""
|
||||
return EmailServiceFactory.create(service_type, config, name)
|
||||
366
src/services/duck_mail.py
Normal file
366
src/services/duck_mail.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
DuckMail 邮箱服务实现
|
||||
兼容 DuckMail 的 accounts/token/messages 接口模型
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from html import unescape
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import BaseEmailService, EmailServiceError, EmailServiceType
|
||||
from ..config.constants import OTP_CODE_PATTERN
|
||||
from ..core.http_client import HTTPClient, RequestConfig
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DuckMailService(BaseEmailService):
|
||||
"""DuckMail 邮箱服务"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None, name: str = None):
|
||||
super().__init__(EmailServiceType.DUCK_MAIL, name)
|
||||
|
||||
required_keys = ["base_url", "default_domain"]
|
||||
missing_keys = [key for key in required_keys if not (config or {}).get(key)]
|
||||
if missing_keys:
|
||||
raise ValueError(f"缺少必需配置: {missing_keys}")
|
||||
|
||||
default_config = {
|
||||
"api_key": "",
|
||||
"password_length": 12,
|
||||
"expires_in": None,
|
||||
"timeout": 30,
|
||||
"max_retries": 3,
|
||||
"proxy_url": None,
|
||||
}
|
||||
self.config = {**default_config, **(config or {})}
|
||||
self.config["base_url"] = str(self.config["base_url"]).rstrip("/")
|
||||
self.config["default_domain"] = str(self.config["default_domain"]).strip().lstrip("@")
|
||||
|
||||
http_config = RequestConfig(
|
||||
timeout=self.config["timeout"],
|
||||
max_retries=self.config["max_retries"],
|
||||
)
|
||||
self.http_client = HTTPClient(
|
||||
proxy_url=self.config.get("proxy_url"),
|
||||
config=http_config,
|
||||
)
|
||||
|
||||
self._accounts_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
self._accounts_by_email: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def _build_headers(
|
||||
self,
|
||||
token: Optional[str] = None,
|
||||
use_api_key: bool = False,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, str]:
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
auth_token = token
|
||||
if not auth_token and use_api_key and self.config.get("api_key"):
|
||||
auth_token = self.config["api_key"]
|
||||
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
return headers
|
||||
|
||||
def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
token: Optional[str] = None,
|
||||
use_api_key: bool = False,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
url = f"{self.config['base_url']}{path}"
|
||||
kwargs["headers"] = self._build_headers(
|
||||
token=token,
|
||||
use_api_key=use_api_key,
|
||||
extra_headers=kwargs.get("headers"),
|
||||
)
|
||||
|
||||
try:
|
||||
response = self.http_client.request(method, url, **kwargs)
|
||||
if response.status_code >= 400:
|
||||
error_message = f"API 请求失败: {response.status_code}"
|
||||
try:
|
||||
error_payload = response.json()
|
||||
error_message = f"{error_message} - {error_payload}"
|
||||
except Exception:
|
||||
error_message = f"{error_message} - {response.text[:200]}"
|
||||
raise EmailServiceError(error_message)
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return {"raw_response": response.text}
|
||||
except Exception as e:
|
||||
self.update_status(False, e)
|
||||
if isinstance(e, EmailServiceError):
|
||||
raise
|
||||
raise EmailServiceError(f"请求失败: {method} {path} - {e}")
|
||||
|
||||
def _generate_local_part(self) -> str:
|
||||
first = random.choice(string.ascii_lowercase)
|
||||
rest = "".join(random.choices(string.ascii_lowercase + string.digits, k=7))
|
||||
return f"{first}{rest}"
|
||||
|
||||
def _generate_password(self) -> str:
|
||||
length = max(6, int(self.config.get("password_length") or 12))
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return "".join(random.choices(alphabet, k=length))
|
||||
|
||||
def _cache_account(self, account_info: Dict[str, Any]) -> None:
|
||||
account_id = str(account_info.get("account_id") or account_info.get("service_id") or "").strip()
|
||||
email = str(account_info.get("email") or "").strip().lower()
|
||||
|
||||
if account_id:
|
||||
self._accounts_by_id[account_id] = account_info
|
||||
if email:
|
||||
self._accounts_by_email[email] = account_info
|
||||
|
||||
def _get_account_info(self, email: Optional[str] = None, email_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
if email_id:
|
||||
cached = self._accounts_by_id.get(str(email_id))
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
if email:
|
||||
cached = self._accounts_by_email.get(str(email).strip().lower())
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
return None
|
||||
|
||||
def _strip_html(self, html_content: Any) -> str:
|
||||
if isinstance(html_content, list):
|
||||
html_content = "\n".join(str(item) for item in html_content if item)
|
||||
text = str(html_content or "")
|
||||
return unescape(re.sub(r"<[^>]+>", " ", text))
|
||||
|
||||
def _parse_message_time(self, value: Optional[str]) -> Optional[float]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
normalized = value.replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(normalized).astimezone(timezone.utc).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _message_search_text(self, summary: Dict[str, Any], detail: Dict[str, Any]) -> str:
|
||||
sender = summary.get("from") or detail.get("from") or {}
|
||||
if isinstance(sender, dict):
|
||||
sender_text = " ".join(
|
||||
str(sender.get(key) or "") for key in ("name", "address")
|
||||
).strip()
|
||||
else:
|
||||
sender_text = str(sender)
|
||||
|
||||
subject = str(summary.get("subject") or detail.get("subject") or "")
|
||||
text_body = str(detail.get("text") or "")
|
||||
html_body = self._strip_html(detail.get("html"))
|
||||
return "\n".join(part for part in [sender_text, subject, text_body, html_body] if part).strip()
|
||||
|
||||
def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
request_config = config or {}
|
||||
local_part = str(request_config.get("name") or self._generate_local_part()).strip()
|
||||
domain = str(request_config.get("default_domain") or request_config.get("domain") or self.config["default_domain"]).strip().lstrip("@")
|
||||
address = f"{local_part}@{domain}"
|
||||
password = self._generate_password()
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"address": address,
|
||||
"password": password,
|
||||
}
|
||||
|
||||
expires_in = request_config.get("expiresIn", request_config.get("expires_in", self.config.get("expires_in")))
|
||||
if expires_in is not None:
|
||||
payload["expiresIn"] = expires_in
|
||||
|
||||
account_response = self._make_request(
|
||||
"POST",
|
||||
"/accounts",
|
||||
json=payload,
|
||||
use_api_key=bool(self.config.get("api_key")),
|
||||
)
|
||||
token_response = self._make_request(
|
||||
"POST",
|
||||
"/token",
|
||||
json={
|
||||
"address": account_response.get("address", address),
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
|
||||
account_id = str(account_response.get("id") or token_response.get("id") or "").strip()
|
||||
resolved_address = str(account_response.get("address") or address).strip()
|
||||
token = str(token_response.get("token") or "").strip()
|
||||
|
||||
if not account_id or not resolved_address or not token:
|
||||
raise EmailServiceError("DuckMail 返回数据不完整")
|
||||
|
||||
email_info = {
|
||||
"email": resolved_address,
|
||||
"service_id": account_id,
|
||||
"id": account_id,
|
||||
"account_id": account_id,
|
||||
"token": token,
|
||||
"password": password,
|
||||
"created_at": time.time(),
|
||||
"raw_account": account_response,
|
||||
}
|
||||
|
||||
self._cache_account(email_info)
|
||||
self.update_status(True)
|
||||
return email_info
|
||||
|
||||
def get_verification_code(
|
||||
self,
|
||||
email: str,
|
||||
email_id: str = None,
|
||||
timeout: int = 120,
|
||||
pattern: str = OTP_CODE_PATTERN,
|
||||
otp_sent_at: Optional[float] = None,
|
||||
) -> Optional[str]:
|
||||
account_info = self._get_account_info(email=email, email_id=email_id)
|
||||
if not account_info:
|
||||
logger.warning(f"DuckMail 未找到邮箱缓存: {email}, {email_id}")
|
||||
return None
|
||||
|
||||
token = account_info.get("token")
|
||||
if not token:
|
||||
logger.warning(f"DuckMail 邮箱缺少访问 token: {email}")
|
||||
return None
|
||||
|
||||
start_time = time.time()
|
||||
seen_message_ids = set()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
response = self._make_request(
|
||||
"GET",
|
||||
"/messages",
|
||||
token=token,
|
||||
params={"page": 1},
|
||||
)
|
||||
messages = response.get("hydra:member", [])
|
||||
|
||||
for message in messages:
|
||||
message_id = str(message.get("id") or "").strip()
|
||||
if not message_id or message_id in seen_message_ids:
|
||||
continue
|
||||
|
||||
created_at = self._parse_message_time(message.get("createdAt"))
|
||||
if otp_sent_at and created_at and created_at + 1 < otp_sent_at:
|
||||
continue
|
||||
|
||||
seen_message_ids.add(message_id)
|
||||
detail = self._make_request(
|
||||
"GET",
|
||||
f"/messages/{message_id}",
|
||||
token=token,
|
||||
)
|
||||
|
||||
content = self._message_search_text(message, detail)
|
||||
if "openai" not in content.lower():
|
||||
continue
|
||||
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
self.update_status(True)
|
||||
return match.group(1)
|
||||
except Exception as e:
|
||||
logger.debug(f"DuckMail 轮询验证码失败: {e}")
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
return None
|
||||
|
||||
def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
return list(self._accounts_by_email.values())
|
||||
|
||||
def delete_email(self, email_id: str) -> bool:
|
||||
account_info = self._get_account_info(email_id=email_id) or self._get_account_info(email=email_id)
|
||||
if not account_info:
|
||||
return False
|
||||
|
||||
token = account_info.get("token")
|
||||
account_id = account_info.get("account_id") or account_info.get("service_id")
|
||||
if not token or not account_id:
|
||||
return False
|
||||
|
||||
try:
|
||||
self._make_request(
|
||||
"DELETE",
|
||||
f"/accounts/{account_id}",
|
||||
token=token,
|
||||
)
|
||||
self._accounts_by_id.pop(str(account_id), None)
|
||||
self._accounts_by_email.pop(str(account_info.get("email") or "").lower(), None)
|
||||
self.update_status(True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"DuckMail 删除邮箱失败: {e}")
|
||||
self.update_status(False, e)
|
||||
return False
|
||||
|
||||
def check_health(self) -> bool:
|
||||
try:
|
||||
self._make_request(
|
||||
"GET",
|
||||
"/domains",
|
||||
params={"page": 1},
|
||||
use_api_key=bool(self.config.get("api_key")),
|
||||
)
|
||||
self.update_status(True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"DuckMail 健康检查失败: {e}")
|
||||
self.update_status(False, e)
|
||||
return False
|
||||
|
||||
def get_email_messages(self, email_id: str, **kwargs) -> List[Dict[str, Any]]:
|
||||
account_info = self._get_account_info(email_id=email_id) or self._get_account_info(email=email_id)
|
||||
if not account_info or not account_info.get("token"):
|
||||
return []
|
||||
response = self._make_request(
|
||||
"GET",
|
||||
"/messages",
|
||||
token=account_info["token"],
|
||||
params={"page": kwargs.get("page", 1)},
|
||||
)
|
||||
return response.get("hydra:member", [])
|
||||
|
||||
def get_message_detail(self, email_id: str, message_id: str) -> Optional[Dict[str, Any]]:
|
||||
account_info = self._get_account_info(email_id=email_id) or self._get_account_info(email=email_id)
|
||||
if not account_info or not account_info.get("token"):
|
||||
return None
|
||||
return self._make_request(
|
||||
"GET",
|
||||
f"/messages/{message_id}",
|
||||
token=account_info["token"],
|
||||
)
|
||||
|
||||
def get_service_info(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"service_type": self.service_type.value,
|
||||
"name": self.name,
|
||||
"base_url": self.config["base_url"],
|
||||
"default_domain": self.config["default_domain"],
|
||||
"cached_accounts": len(self._accounts_by_email),
|
||||
"status": self.status.value,
|
||||
}
|
||||
324
src/services/freemail.py
Normal file
324
src/services/freemail.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Freemail 邮箱服务实现
|
||||
基于自部署 Cloudflare Worker 临时邮箱服务 (https://github.com/idinging/freemail)
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from .base import BaseEmailService, EmailServiceError, EmailServiceType
|
||||
from ..core.http_client import HTTPClient, RequestConfig
|
||||
from ..config.constants import OTP_CODE_PATTERN
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FreemailService(BaseEmailService):
|
||||
"""
|
||||
Freemail 邮箱服务
|
||||
基于自部署 Cloudflare Worker 的临时邮箱
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None, name: str = None):
|
||||
"""
|
||||
初始化 Freemail 服务
|
||||
|
||||
Args:
|
||||
config: 配置字典,支持以下键:
|
||||
- base_url: Worker 域名地址 (必需)
|
||||
- admin_token: Admin Token,对应 JWT_TOKEN (必需)
|
||||
- domain: 邮箱域名,如 example.com
|
||||
- timeout: 请求超时时间,默认 30
|
||||
- max_retries: 最大重试次数,默认 3
|
||||
name: 服务名称
|
||||
"""
|
||||
super().__init__(EmailServiceType.FREEMAIL, name)
|
||||
|
||||
required_keys = ["base_url", "admin_token"]
|
||||
missing_keys = [key for key in required_keys if not (config or {}).get(key)]
|
||||
if missing_keys:
|
||||
raise ValueError(f"缺少必需配置: {missing_keys}")
|
||||
|
||||
default_config = {
|
||||
"timeout": 30,
|
||||
"max_retries": 3,
|
||||
}
|
||||
self.config = {**default_config, **(config or {})}
|
||||
self.config["base_url"] = self.config["base_url"].rstrip("/")
|
||||
|
||||
http_config = RequestConfig(
|
||||
timeout=self.config["timeout"],
|
||||
max_retries=self.config["max_retries"],
|
||||
)
|
||||
self.http_client = HTTPClient(proxy_url=None, config=http_config)
|
||||
|
||||
# 缓存 domain 列表
|
||||
self._domains = []
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""构造 admin 请求头"""
|
||||
return {
|
||||
"Authorization": f"Bearer {self.config['admin_token']}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
def _make_request(self, method: str, path: str, **kwargs) -> Any:
|
||||
"""
|
||||
发送请求并返回 JSON 数据
|
||||
|
||||
Args:
|
||||
method: HTTP 方法
|
||||
path: 请求路径(以 / 开头)
|
||||
**kwargs: 传递给 http_client.request 的额外参数
|
||||
|
||||
Returns:
|
||||
响应 JSON 数据
|
||||
|
||||
Raises:
|
||||
EmailServiceError: 请求失败
|
||||
"""
|
||||
url = f"{self.config['base_url']}{path}"
|
||||
kwargs.setdefault("headers", {})
|
||||
kwargs["headers"].update(self._get_headers())
|
||||
|
||||
try:
|
||||
response = self.http_client.request(method, url, **kwargs)
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_msg = f"请求失败: {response.status_code}"
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_msg = f"{error_msg} - {error_data}"
|
||||
except Exception:
|
||||
error_msg = f"{error_msg} - {response.text[:200]}"
|
||||
self.update_status(False, EmailServiceError(error_msg))
|
||||
raise EmailServiceError(error_msg)
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return {"raw_response": response.text}
|
||||
|
||||
except Exception as e:
|
||||
self.update_status(False, e)
|
||||
if isinstance(e, EmailServiceError):
|
||||
raise
|
||||
raise EmailServiceError(f"请求失败: {method} {path} - {e}")
|
||||
|
||||
def _ensure_domains(self):
|
||||
"""获取并缓存可用域名列表"""
|
||||
if not self._domains:
|
||||
try:
|
||||
domains = self._make_request("GET", "/api/domains")
|
||||
if isinstance(domains, list):
|
||||
self._domains = domains
|
||||
except Exception as e:
|
||||
logger.warning(f"获取 Freemail 域名列表失败: {e}")
|
||||
|
||||
def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
通过 API 创建临时邮箱
|
||||
|
||||
Returns:
|
||||
包含邮箱信息的字典:
|
||||
- email: 邮箱地址
|
||||
- service_id: 同 email(用作标识)
|
||||
"""
|
||||
self._ensure_domains()
|
||||
|
||||
req_config = config or {}
|
||||
domain_index = 0
|
||||
target_domain = req_config.get("domain") or self.config.get("domain")
|
||||
|
||||
if target_domain and self._domains:
|
||||
for i, d in enumerate(self._domains):
|
||||
if d == target_domain:
|
||||
domain_index = i
|
||||
break
|
||||
|
||||
prefix = req_config.get("name")
|
||||
try:
|
||||
if prefix:
|
||||
body = {
|
||||
"local": prefix,
|
||||
"domainIndex": domain_index
|
||||
}
|
||||
resp = self._make_request("POST", "/api/create", json=body)
|
||||
else:
|
||||
params = {"domainIndex": domain_index}
|
||||
length = req_config.get("length")
|
||||
if length:
|
||||
params["length"] = length
|
||||
resp = self._make_request("GET", "/api/generate", params=params)
|
||||
|
||||
email = resp.get("email")
|
||||
if not email:
|
||||
raise EmailServiceError(f"创建邮箱失败,未返回邮箱地址: {resp}")
|
||||
|
||||
email_info = {
|
||||
"email": email,
|
||||
"service_id": email,
|
||||
"id": email,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
|
||||
logger.info(f"成功创建 Freemail 邮箱: {email}")
|
||||
self.update_status(True)
|
||||
return email_info
|
||||
|
||||
except Exception as e:
|
||||
self.update_status(False, e)
|
||||
if isinstance(e, EmailServiceError):
|
||||
raise
|
||||
raise EmailServiceError(f"创建邮箱失败: {e}")
|
||||
|
||||
def get_verification_code(
|
||||
self,
|
||||
email: str,
|
||||
email_id: str = None,
|
||||
timeout: int = 120,
|
||||
pattern: str = OTP_CODE_PATTERN,
|
||||
otp_sent_at: Optional[float] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
从 Freemail 邮箱获取验证码
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
email_id: 未使用,保留接口兼容
|
||||
timeout: 超时时间(秒)
|
||||
pattern: 验证码正则
|
||||
otp_sent_at: OTP 发送时间戳(暂未使用)
|
||||
|
||||
Returns:
|
||||
验证码字符串,超时返回 None
|
||||
"""
|
||||
logger.info(f"正在从 Freemail 邮箱 {email} 获取验证码...")
|
||||
|
||||
start_time = time.time()
|
||||
seen_mail_ids: set = set()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
mails = self._make_request("GET", "/api/emails", params={"mailbox": email, "limit": 20})
|
||||
if not isinstance(mails, list):
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
for mail in mails:
|
||||
mail_id = mail.get("id")
|
||||
if not mail_id or mail_id in seen_mail_ids:
|
||||
continue
|
||||
|
||||
seen_mail_ids.add(mail_id)
|
||||
|
||||
sender = str(mail.get("sender", "")).lower()
|
||||
subject = str(mail.get("subject", ""))
|
||||
preview = str(mail.get("preview", ""))
|
||||
|
||||
content = f"{sender}\n{subject}\n{preview}"
|
||||
|
||||
if "openai" not in content.lower():
|
||||
continue
|
||||
|
||||
# 尝试直接使用 Freemail 提取的验证码
|
||||
v_code = mail.get("verification_code")
|
||||
if v_code:
|
||||
logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {v_code}")
|
||||
self.update_status(True)
|
||||
return v_code
|
||||
|
||||
# 如果没有直接提供,通过正则匹配 preview
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
code = match.group(1)
|
||||
logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {code}")
|
||||
self.update_status(True)
|
||||
return code
|
||||
|
||||
# 如果依然未找到,获取邮件详情进行匹配
|
||||
try:
|
||||
detail = self._make_request("GET", f"/api/email/{mail_id}")
|
||||
full_content = str(detail.get("content", "")) + "\n" + str(detail.get("html_content", ""))
|
||||
match = re.search(pattern, full_content)
|
||||
if match:
|
||||
code = match.group(1)
|
||||
logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {code}")
|
||||
self.update_status(True)
|
||||
return code
|
||||
except Exception as e:
|
||||
logger.debug(f"获取 Freemail 邮件详情失败: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"检查 Freemail 邮件时出错: {e}")
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
logger.warning(f"等待 Freemail 验证码超时: {email}")
|
||||
return None
|
||||
|
||||
def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
列出邮箱
|
||||
|
||||
Args:
|
||||
**kwargs: 额外查询参数
|
||||
|
||||
Returns:
|
||||
邮箱列表
|
||||
"""
|
||||
try:
|
||||
params = {
|
||||
"limit": kwargs.get("limit", 100),
|
||||
"offset": kwargs.get("offset", 0)
|
||||
}
|
||||
resp = self._make_request("GET", "/api/mailboxes", params=params)
|
||||
|
||||
emails = []
|
||||
if isinstance(resp, list):
|
||||
for mail in resp:
|
||||
address = mail.get("address")
|
||||
if address:
|
||||
emails.append({
|
||||
"id": address,
|
||||
"service_id": address,
|
||||
"email": address,
|
||||
"created_at": mail.get("created_at"),
|
||||
"raw_data": mail
|
||||
})
|
||||
self.update_status(True)
|
||||
return emails
|
||||
except Exception as e:
|
||||
logger.warning(f"列出 Freemail 邮箱失败: {e}")
|
||||
self.update_status(False, e)
|
||||
return []
|
||||
|
||||
def delete_email(self, email_id: str) -> bool:
|
||||
"""
|
||||
删除邮箱
|
||||
"""
|
||||
try:
|
||||
self._make_request("DELETE", "/api/mailboxes", params={"address": email_id})
|
||||
logger.info(f"已删除 Freemail 邮箱: {email_id}")
|
||||
self.update_status(True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"删除 Freemail 邮箱失败: {e}")
|
||||
self.update_status(False, e)
|
||||
return False
|
||||
|
||||
def check_health(self) -> bool:
|
||||
"""检查服务健康状态"""
|
||||
try:
|
||||
self._make_request("GET", "/api/domains")
|
||||
self.update_status(True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Freemail 健康检查失败: {e}")
|
||||
self.update_status(False, e)
|
||||
return False
|
||||
217
src/services/imap_mail.py
Normal file
217
src/services/imap_mail.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
IMAP 邮箱服务
|
||||
支持 Gmail / QQ / 163 / Yahoo / Outlook 等标准 IMAP 协议邮件服务商。
|
||||
仅用于接收验证码,强制直连(imaplib 不支持代理)。
|
||||
"""
|
||||
|
||||
import imaplib
|
||||
import email
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from email.header import decode_header
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .base import BaseEmailService, EmailServiceError
|
||||
from ..config.constants import (
|
||||
EmailServiceType,
|
||||
OPENAI_EMAIL_SENDERS,
|
||||
OTP_CODE_SEMANTIC_PATTERN,
|
||||
OTP_CODE_PATTERN,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImapMailService(BaseEmailService):
|
||||
"""标准 IMAP 邮箱服务(仅接收验证码,强制直连)"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None, name: str = None):
|
||||
super().__init__(EmailServiceType.IMAP_MAIL, name)
|
||||
|
||||
cfg = config or {}
|
||||
required_keys = ["host", "email", "password"]
|
||||
missing_keys = [k for k in required_keys if not cfg.get(k)]
|
||||
if missing_keys:
|
||||
raise ValueError(f"缺少必需配置: {missing_keys}")
|
||||
|
||||
self.host: str = str(cfg["host"]).strip()
|
||||
self.port: int = int(cfg.get("port", 993))
|
||||
self.use_ssl: bool = bool(cfg.get("use_ssl", True))
|
||||
self.email_addr: str = str(cfg["email"]).strip()
|
||||
self.password: str = str(cfg["password"])
|
||||
self.timeout: int = int(cfg.get("timeout", 30))
|
||||
self.max_retries: int = int(cfg.get("max_retries", 3))
|
||||
|
||||
def _connect(self) -> imaplib.IMAP4:
|
||||
"""建立 IMAP 连接并登录,返回 mail 对象"""
|
||||
if self.use_ssl:
|
||||
mail = imaplib.IMAP4_SSL(self.host, self.port)
|
||||
else:
|
||||
mail = imaplib.IMAP4(self.host, self.port)
|
||||
mail.starttls()
|
||||
mail.login(self.email_addr, self.password)
|
||||
return mail
|
||||
|
||||
def _decode_str(self, value) -> str:
|
||||
"""解码邮件头部字段"""
|
||||
if value is None:
|
||||
return ""
|
||||
parts = decode_header(value)
|
||||
decoded = []
|
||||
for part, charset in parts:
|
||||
if isinstance(part, bytes):
|
||||
decoded.append(part.decode(charset or "utf-8", errors="replace"))
|
||||
else:
|
||||
decoded.append(str(part))
|
||||
return " ".join(decoded)
|
||||
|
||||
def _get_text_body(self, msg) -> str:
|
||||
"""提取邮件纯文本内容"""
|
||||
body = ""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain":
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
body += payload.decode(charset, errors="replace")
|
||||
else:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
body = payload.decode(charset, errors="replace")
|
||||
return body
|
||||
|
||||
def _is_openai_sender(self, from_addr: str) -> bool:
|
||||
"""判断发件人是否为 OpenAI"""
|
||||
from_lower = from_addr.lower()
|
||||
for sender in OPENAI_EMAIL_SENDERS:
|
||||
if sender.startswith("@") or sender.startswith("."):
|
||||
if sender in from_lower:
|
||||
return True
|
||||
else:
|
||||
if sender in from_lower:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _extract_otp(self, text: str) -> Optional[str]:
|
||||
"""从文本中提取 6 位验证码,优先语义匹配,回退简单匹配"""
|
||||
match = re.search(OTP_CODE_SEMANTIC_PATTERN, text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
match = re.search(OTP_CODE_PATTERN, text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""IMAP 模式不创建新邮箱,直接返回配置中的固定地址"""
|
||||
self.update_status(True)
|
||||
return {
|
||||
"email": self.email_addr,
|
||||
"service_id": self.email_addr,
|
||||
"id": self.email_addr,
|
||||
}
|
||||
|
||||
def get_verification_code(
|
||||
self,
|
||||
email: str,
|
||||
email_id: str = None,
|
||||
timeout: int = 60,
|
||||
pattern: str = None,
|
||||
otp_sent_at: Optional[float] = None,
|
||||
) -> Optional[str]:
|
||||
"""轮询 IMAP 收件箱,获取 OpenAI 验证码"""
|
||||
start_time = time.time()
|
||||
seen_ids: set = set()
|
||||
mail = None
|
||||
|
||||
try:
|
||||
mail = self._connect()
|
||||
mail.select("INBOX")
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
# 搜索所有未读邮件
|
||||
status, data = mail.search(None, "UNSEEN")
|
||||
if status != "OK" or not data or not data[0]:
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
msg_ids = data[0].split()
|
||||
for msg_id in reversed(msg_ids): # 最新的优先
|
||||
id_str = msg_id.decode()
|
||||
if id_str in seen_ids:
|
||||
continue
|
||||
seen_ids.add(id_str)
|
||||
|
||||
# 获取邮件
|
||||
status, msg_data = mail.fetch(msg_id, "(RFC822)")
|
||||
if status != "OK" or not msg_data:
|
||||
continue
|
||||
|
||||
raw = msg_data[0][1]
|
||||
msg = email.message_from_bytes(raw)
|
||||
|
||||
# 检查发件人
|
||||
from_addr = self._decode_str(msg.get("From", ""))
|
||||
if not self._is_openai_sender(from_addr):
|
||||
continue
|
||||
|
||||
# 提取验证码
|
||||
body = self._get_text_body(msg)
|
||||
code = self._extract_otp(body)
|
||||
if code:
|
||||
# 标记已读
|
||||
mail.store(msg_id, "+FLAGS", "\\Seen")
|
||||
self.update_status(True)
|
||||
logger.info(f"IMAP 获取验证码成功: {code}")
|
||||
return code
|
||||
|
||||
except imaplib.IMAP4.error as e:
|
||||
logger.debug(f"IMAP 搜索邮件失败: {e}")
|
||||
# 尝试重新连接
|
||||
try:
|
||||
mail.select("INBOX")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"IMAP 连接/轮询失败: {e}")
|
||||
self.update_status(False, str(e))
|
||||
finally:
|
||||
if mail:
|
||||
try:
|
||||
mail.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def check_health(self) -> bool:
|
||||
"""尝试 IMAP 登录并选择收件箱"""
|
||||
mail = None
|
||||
try:
|
||||
mail = self._connect()
|
||||
status, _ = mail.select("INBOX")
|
||||
return status == "OK"
|
||||
except Exception as e:
|
||||
logger.warning(f"IMAP 健康检查失败: {e}")
|
||||
return False
|
||||
finally:
|
||||
if mail:
|
||||
try:
|
||||
mail.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def list_emails(self, **kwargs) -> list:
|
||||
"""IMAP 单账号模式,返回固定地址"""
|
||||
return [{"email": self.email_addr, "id": self.email_addr}]
|
||||
|
||||
def delete_email(self, email_id: str) -> bool:
|
||||
"""IMAP 模式无需删除逻辑"""
|
||||
return True
|
||||
556
src/services/moe_mail.py
Normal file
556
src/services/moe_mail.py
Normal file
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
自定义域名邮箱服务实现
|
||||
基于 email.md 中的 REST API 接口
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from .base import BaseEmailService, EmailServiceError, EmailServiceType
|
||||
from ..core.http_client import HTTPClient, RequestConfig
|
||||
from ..config.constants import OTP_CODE_PATTERN
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MeoMailEmailService(BaseEmailService):
|
||||
"""
|
||||
自定义域名邮箱服务
|
||||
基于 REST API 接口
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None, name: str = None):
|
||||
"""
|
||||
初始化自定义域名邮箱服务
|
||||
|
||||
Args:
|
||||
config: 配置字典,支持以下键:
|
||||
- base_url: API 基础地址 (必需)
|
||||
- api_key: API 密钥 (必需)
|
||||
- api_key_header: API 密钥请求头名称 (默认: X-API-Key)
|
||||
- timeout: 请求超时时间 (默认: 30)
|
||||
- max_retries: 最大重试次数 (默认: 3)
|
||||
- proxy_url: 代理 URL
|
||||
- default_domain: 默认域名
|
||||
- default_expiry: 默认过期时间(毫秒)
|
||||
name: 服务名称
|
||||
"""
|
||||
super().__init__(EmailServiceType.MOE_MAIL, name)
|
||||
|
||||
# 必需配置检查
|
||||
required_keys = ["base_url", "api_key"]
|
||||
missing_keys = [key for key in required_keys if key not in (config or {})]
|
||||
|
||||
if missing_keys:
|
||||
raise ValueError(f"缺少必需配置: {missing_keys}")
|
||||
|
||||
# 默认配置
|
||||
default_config = {
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"api_key_header": "X-API-Key",
|
||||
"timeout": 30,
|
||||
"max_retries": 3,
|
||||
"proxy_url": None,
|
||||
"default_domain": None,
|
||||
"default_expiry": 3600000, # 1小时
|
||||
}
|
||||
|
||||
self.config = {**default_config, **(config or {})}
|
||||
|
||||
# 创建 HTTP 客户端
|
||||
http_config = RequestConfig(
|
||||
timeout=self.config["timeout"],
|
||||
max_retries=self.config["max_retries"],
|
||||
)
|
||||
self.http_client = HTTPClient(
|
||||
proxy_url=self.config.get("proxy_url"),
|
||||
config=http_config
|
||||
)
|
||||
|
||||
# 状态变量
|
||||
self._emails_cache: Dict[str, Dict[str, Any]] = {}
|
||||
self._last_config_check: float = 0
|
||||
self._cached_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""获取 API 请求头"""
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# 添加 API 密钥
|
||||
api_key_header = self.config.get("api_key_header", "X-API-Key")
|
||||
headers[api_key_header] = self.config["api_key"]
|
||||
|
||||
return headers
|
||||
|
||||
def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
||||
"""
|
||||
发送 API 请求
|
||||
|
||||
Args:
|
||||
method: HTTP 方法
|
||||
endpoint: API 端点
|
||||
**kwargs: 请求参数
|
||||
|
||||
Returns:
|
||||
响应 JSON 数据
|
||||
|
||||
Raises:
|
||||
EmailServiceError: 请求失败
|
||||
"""
|
||||
url = urljoin(self.config["base_url"], endpoint)
|
||||
|
||||
# 添加默认请求头
|
||||
kwargs.setdefault("headers", {})
|
||||
kwargs["headers"].update(self._get_headers())
|
||||
|
||||
try:
|
||||
# POST 请求禁用自动重定向,手动处理以保持 POST 方法(避免 HTTP→HTTPS 重定向时被转为 GET)
|
||||
if method.upper() == "POST":
|
||||
kwargs["allow_redirects"] = False
|
||||
response = self.http_client.request(method, url, **kwargs)
|
||||
# 处理重定向
|
||||
max_redirects = 5
|
||||
redirect_count = 0
|
||||
while response.status_code in (301, 302, 303, 307, 308) and redirect_count < max_redirects:
|
||||
location = response.headers.get("Location", "")
|
||||
if not location:
|
||||
break
|
||||
import urllib.parse as _urlparse
|
||||
redirect_url = _urlparse.urljoin(url, location)
|
||||
# 307/308 保持 POST,其余(301/302/303)转为 GET
|
||||
if response.status_code in (307, 308):
|
||||
redirect_method = method
|
||||
redirect_kwargs = kwargs
|
||||
else:
|
||||
redirect_method = "GET"
|
||||
# GET 不传 body
|
||||
redirect_kwargs = {k: v for k, v in kwargs.items() if k not in ("json", "data")}
|
||||
response = self.http_client.request(redirect_method, redirect_url, **redirect_kwargs)
|
||||
url = redirect_url
|
||||
redirect_count += 1
|
||||
else:
|
||||
response = self.http_client.request(method, url, **kwargs)
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_msg = f"API 请求失败: {response.status_code}"
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_msg = f"{error_msg} - {error_data}"
|
||||
except:
|
||||
error_msg = f"{error_msg} - {response.text[:200]}"
|
||||
|
||||
self.update_status(False, EmailServiceError(error_msg))
|
||||
raise EmailServiceError(error_msg)
|
||||
|
||||
# 解析响应
|
||||
try:
|
||||
return response.json()
|
||||
except json.JSONDecodeError:
|
||||
return {"raw_response": response.text}
|
||||
|
||||
except Exception as e:
|
||||
self.update_status(False, e)
|
||||
if isinstance(e, EmailServiceError):
|
||||
raise
|
||||
raise EmailServiceError(f"API 请求失败: {method} {endpoint} - {e}")
|
||||
|
||||
def get_config(self, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
获取系统配置
|
||||
|
||||
Args:
|
||||
force_refresh: 是否强制刷新缓存
|
||||
|
||||
Returns:
|
||||
配置信息
|
||||
"""
|
||||
# 检查缓存
|
||||
if not force_refresh and self._cached_config and time.time() - self._last_config_check < 300:
|
||||
return self._cached_config
|
||||
|
||||
try:
|
||||
response = self._make_request("GET", "/api/config")
|
||||
self._cached_config = response
|
||||
self._last_config_check = time.time()
|
||||
self.update_status(True)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.warning(f"获取配置失败: {e}")
|
||||
return {}
|
||||
|
||||
def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
创建临时邮箱
|
||||
|
||||
Args:
|
||||
config: 配置参数:
|
||||
- name: 邮箱前缀(可选)
|
||||
- expiryTime: 有效期(毫秒)(可选)
|
||||
- domain: 邮箱域名(可选)
|
||||
|
||||
Returns:
|
||||
包含邮箱信息的字典:
|
||||
- email: 邮箱地址
|
||||
- service_id: 邮箱 ID
|
||||
- id: 邮箱 ID(同 service_id)
|
||||
- expiry: 过期时间信息
|
||||
"""
|
||||
# 获取默认配置
|
||||
sys_config = self.get_config()
|
||||
default_domain = self.config.get("default_domain")
|
||||
if not default_domain and sys_config.get("emailDomains"):
|
||||
# 使用系统配置的第一个域名
|
||||
domains = sys_config["emailDomains"].split(",")
|
||||
default_domain = domains[0].strip() if domains else None
|
||||
|
||||
# 构建请求参数
|
||||
request_config = config or {}
|
||||
create_data = {
|
||||
"name": request_config.get("name", ""),
|
||||
"expiryTime": request_config.get("expiryTime", self.config.get("default_expiry", 3600000)),
|
||||
"domain": request_config.get("domain", default_domain),
|
||||
}
|
||||
|
||||
# 移除空值
|
||||
create_data = {k: v for k, v in create_data.items() if v is not None and v != ""}
|
||||
|
||||
try:
|
||||
response = self._make_request("POST", "/api/emails/generate", json=create_data)
|
||||
|
||||
email = response.get("email", "").strip()
|
||||
email_id = response.get("id", "").strip()
|
||||
|
||||
if not email or not email_id:
|
||||
raise EmailServiceError("API 返回数据不完整")
|
||||
|
||||
email_info = {
|
||||
"email": email,
|
||||
"service_id": email_id,
|
||||
"id": email_id,
|
||||
"created_at": time.time(),
|
||||
"expiry": create_data.get("expiryTime"),
|
||||
"domain": create_data.get("domain"),
|
||||
"raw_response": response,
|
||||
}
|
||||
|
||||
# 缓存邮箱信息
|
||||
self._emails_cache[email_id] = email_info
|
||||
|
||||
logger.info(f"成功创建自定义域名邮箱: {email} (ID: {email_id})")
|
||||
self.update_status(True)
|
||||
return email_info
|
||||
|
||||
except Exception as e:
|
||||
self.update_status(False, e)
|
||||
if isinstance(e, EmailServiceError):
|
||||
raise
|
||||
raise EmailServiceError(f"创建邮箱失败: {e}")
|
||||
|
||||
def get_verification_code(
|
||||
self,
|
||||
email: str,
|
||||
email_id: str = None,
|
||||
timeout: int = 120,
|
||||
pattern: str = OTP_CODE_PATTERN,
|
||||
otp_sent_at: Optional[float] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
从自定义域名邮箱获取验证码
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
email_id: 邮箱 ID(如果不提供,从缓存中查找)
|
||||
timeout: 超时时间(秒)
|
||||
pattern: 验证码正则表达式
|
||||
otp_sent_at: OTP 发送时间戳(自定义域名服务暂不使用此参数)
|
||||
|
||||
Returns:
|
||||
验证码字符串,如果超时或未找到返回 None
|
||||
"""
|
||||
# 查找邮箱 ID
|
||||
target_email_id = email_id
|
||||
if not target_email_id:
|
||||
# 从缓存中查找
|
||||
for eid, info in self._emails_cache.items():
|
||||
if info.get("email") == email:
|
||||
target_email_id = eid
|
||||
break
|
||||
|
||||
if not target_email_id:
|
||||
logger.warning(f"未找到邮箱 {email} 的 ID,无法获取验证码")
|
||||
return None
|
||||
|
||||
logger.info(f"正在从自定义域名邮箱 {email} 获取验证码...")
|
||||
|
||||
start_time = time.time()
|
||||
seen_message_ids = set()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
# 获取邮件列表
|
||||
response = self._make_request("GET", f"/api/emails/{target_email_id}")
|
||||
|
||||
messages = response.get("messages", [])
|
||||
if not isinstance(messages, list):
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
for message in messages:
|
||||
message_id = message.get("id")
|
||||
if not message_id or message_id in seen_message_ids:
|
||||
continue
|
||||
|
||||
seen_message_ids.add(message_id)
|
||||
|
||||
# 检查是否是目标邮件
|
||||
sender = str(message.get("from_address", "")).lower()
|
||||
subject = str(message.get("subject", ""))
|
||||
|
||||
# 获取邮件内容
|
||||
message_content = self._get_message_content(target_email_id, message_id)
|
||||
if not message_content:
|
||||
continue
|
||||
|
||||
content = f"{sender} {subject} {message_content}"
|
||||
|
||||
# 检查是否是 OpenAI 邮件
|
||||
if "openai" not in sender and "openai" not in content.lower():
|
||||
continue
|
||||
|
||||
# 提取验证码 过滤掉邮箱
|
||||
email_pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
|
||||
match = re.search(pattern, re.sub(email_pattern, "", content))
|
||||
if match:
|
||||
code = match.group(1)
|
||||
logger.info(f"从自定义域名邮箱 {email} 找到验证码: {code}")
|
||||
self.update_status(True)
|
||||
return code
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"检查邮件时出错: {e}")
|
||||
|
||||
# 等待一段时间再检查
|
||||
time.sleep(3)
|
||||
|
||||
logger.warning(f"等待验证码超时: {email}")
|
||||
return None
|
||||
|
||||
def _get_message_content(self, email_id: str, message_id: str) -> Optional[str]:
|
||||
"""获取邮件内容"""
|
||||
try:
|
||||
response = self._make_request("GET", f"/api/emails/{email_id}/{message_id}")
|
||||
message = response.get("message", {})
|
||||
|
||||
# 优先使用纯文本内容,其次使用 HTML 内容
|
||||
content = message.get("content", "")
|
||||
if not content:
|
||||
html = message.get("html", "")
|
||||
if html:
|
||||
# 简单去除 HTML 标签
|
||||
content = re.sub(r"<[^>]+>", " ", html)
|
||||
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.debug(f"获取邮件内容失败: {e}")
|
||||
return None
|
||||
|
||||
def list_emails(self, cursor: str = None, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
列出所有邮箱
|
||||
|
||||
Args:
|
||||
cursor: 分页游标
|
||||
**kwargs: 其他参数
|
||||
|
||||
Returns:
|
||||
邮箱列表
|
||||
"""
|
||||
params = {}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
|
||||
try:
|
||||
response = self._make_request("GET", "/api/emails", params=params)
|
||||
emails = response.get("emails", [])
|
||||
|
||||
# 更新缓存
|
||||
for email_info in emails:
|
||||
email_id = email_info.get("id")
|
||||
if email_id:
|
||||
self._emails_cache[email_id] = email_info
|
||||
|
||||
self.update_status(True)
|
||||
return emails
|
||||
except Exception as e:
|
||||
logger.warning(f"列出邮箱失败: {e}")
|
||||
self.update_status(False, e)
|
||||
return []
|
||||
|
||||
def delete_email(self, email_id: str) -> bool:
|
||||
"""
|
||||
删除邮箱
|
||||
|
||||
Args:
|
||||
email_id: 邮箱 ID
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
try:
|
||||
response = self._make_request("DELETE", f"/api/emails/{email_id}")
|
||||
success = response.get("success", False)
|
||||
|
||||
if success:
|
||||
# 从缓存中移除
|
||||
self._emails_cache.pop(email_id, None)
|
||||
logger.info(f"成功删除邮箱: {email_id}")
|
||||
else:
|
||||
logger.warning(f"删除邮箱失败: {email_id}")
|
||||
|
||||
self.update_status(success)
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"删除邮箱失败: {email_id} - {e}")
|
||||
self.update_status(False, e)
|
||||
return False
|
||||
|
||||
def check_health(self) -> bool:
|
||||
"""检查自定义域名邮箱服务是否可用"""
|
||||
try:
|
||||
# 尝试获取配置
|
||||
config = self.get_config(force_refresh=True)
|
||||
if config:
|
||||
logger.debug(f"自定义域名邮箱服务健康检查通过,配置: {config.get('defaultRole', 'N/A')}")
|
||||
self.update_status(True)
|
||||
return True
|
||||
else:
|
||||
logger.warning("自定义域名邮箱服务健康检查失败:获取配置为空")
|
||||
self.update_status(False, EmailServiceError("获取配置为空"))
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"自定义域名邮箱服务健康检查失败: {e}")
|
||||
self.update_status(False, e)
|
||||
return False
|
||||
|
||||
def get_email_messages(self, email_id: str, cursor: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取邮箱中的邮件列表
|
||||
|
||||
Args:
|
||||
email_id: 邮箱 ID
|
||||
cursor: 分页游标
|
||||
|
||||
Returns:
|
||||
邮件列表
|
||||
"""
|
||||
params = {}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
|
||||
try:
|
||||
response = self._make_request("GET", f"/api/emails/{email_id}", params=params)
|
||||
messages = response.get("messages", [])
|
||||
self.update_status(True)
|
||||
return messages
|
||||
except Exception as e:
|
||||
logger.error(f"获取邮件列表失败: {email_id} - {e}")
|
||||
self.update_status(False, e)
|
||||
return []
|
||||
|
||||
def get_message_detail(self, email_id: str, message_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取邮件详情
|
||||
|
||||
Args:
|
||||
email_id: 邮箱 ID
|
||||
message_id: 邮件 ID
|
||||
|
||||
Returns:
|
||||
邮件详情
|
||||
"""
|
||||
try:
|
||||
response = self._make_request("GET", f"/api/emails/{email_id}/{message_id}")
|
||||
message = response.get("message")
|
||||
self.update_status(True)
|
||||
return message
|
||||
except Exception as e:
|
||||
logger.error(f"获取邮件详情失败: {email_id}/{message_id} - {e}")
|
||||
self.update_status(False, e)
|
||||
return None
|
||||
|
||||
def create_email_share(self, email_id: str, expires_in: int = 86400000) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
创建邮箱分享链接
|
||||
|
||||
Args:
|
||||
email_id: 邮箱 ID
|
||||
expires_in: 有效期(毫秒)
|
||||
|
||||
Returns:
|
||||
分享信息
|
||||
"""
|
||||
try:
|
||||
response = self._make_request(
|
||||
"POST",
|
||||
f"/api/emails/{email_id}/share",
|
||||
json={"expiresIn": expires_in}
|
||||
)
|
||||
self.update_status(True)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"创建邮箱分享链接失败: {email_id} - {e}")
|
||||
self.update_status(False, e)
|
||||
return None
|
||||
|
||||
def create_message_share(
|
||||
self,
|
||||
email_id: str,
|
||||
message_id: str,
|
||||
expires_in: int = 86400000
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
创建邮件分享链接
|
||||
|
||||
Args:
|
||||
email_id: 邮箱 ID
|
||||
message_id: 邮件 ID
|
||||
expires_in: 有效期(毫秒)
|
||||
|
||||
Returns:
|
||||
分享信息
|
||||
"""
|
||||
try:
|
||||
response = self._make_request(
|
||||
"POST",
|
||||
f"/api/emails/{email_id}/messages/{message_id}/share",
|
||||
json={"expiresIn": expires_in}
|
||||
)
|
||||
self.update_status(True)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"创建邮件分享链接失败: {email_id}/{message_id} - {e}")
|
||||
self.update_status(False, e)
|
||||
return None
|
||||
|
||||
def get_service_info(self) -> Dict[str, Any]:
|
||||
"""获取服务信息"""
|
||||
config = self.get_config()
|
||||
return {
|
||||
"service_type": self.service_type.value,
|
||||
"name": self.name,
|
||||
"base_url": self.config["base_url"],
|
||||
"default_domain": self.config.get("default_domain"),
|
||||
"system_config": config,
|
||||
"cached_emails_count": len(self._emails_cache),
|
||||
"status": self.status.value,
|
||||
}
|
||||
8
src/services/outlook/__init__.py
Normal file
8
src/services/outlook/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Outlook 邮箱服务模块
|
||||
支持多种 IMAP/API 连接方式,自动故障切换
|
||||
"""
|
||||
|
||||
from .service import OutlookService
|
||||
|
||||
__all__ = ['OutlookService']
|
||||
51
src/services/outlook/account.py
Normal file
51
src/services/outlook/account.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Outlook 账户数据类
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutlookAccount:
|
||||
"""Outlook 账户信息"""
|
||||
email: str
|
||||
password: str = ""
|
||||
client_id: str = ""
|
||||
refresh_token: str = ""
|
||||
|
||||
@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()
|
||||
|
||||
def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
result = {
|
||||
"email": self.email,
|
||||
"has_oauth": self.has_oauth(),
|
||||
}
|
||||
if include_sensitive:
|
||||
result.update({
|
||||
"password": self.password,
|
||||
"client_id": self.client_id,
|
||||
"refresh_token": self.refresh_token[:20] + "..." if self.refresh_token else "",
|
||||
})
|
||||
return result
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""字符串表示"""
|
||||
return f"OutlookAccount({self.email})"
|
||||
153
src/services/outlook/base.py
Normal file
153
src/services/outlook/base.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Outlook 服务基础定义
|
||||
包含枚举类型和数据类
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
|
||||
class ProviderType(str, Enum):
|
||||
"""Outlook 提供者类型"""
|
||||
IMAP_OLD = "imap_old" # 旧版 IMAP (outlook.office365.com)
|
||||
IMAP_NEW = "imap_new" # 新版 IMAP (outlook.live.com)
|
||||
GRAPH_API = "graph_api" # Microsoft Graph API
|
||||
|
||||
|
||||
class TokenEndpoint(str, Enum):
|
||||
"""Token 端点"""
|
||||
LIVE = "https://login.live.com/oauth20_token.srf"
|
||||
CONSUMERS = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
|
||||
COMMON = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
|
||||
|
||||
|
||||
class IMAPServer(str, Enum):
|
||||
"""IMAP 服务器"""
|
||||
OLD = "outlook.office365.com"
|
||||
NEW = "outlook.live.com"
|
||||
|
||||
|
||||
class ProviderStatus(str, Enum):
|
||||
"""提供者状态"""
|
||||
HEALTHY = "healthy" # 健康
|
||||
DEGRADED = "degraded" # 降级
|
||||
DISABLED = "disabled" # 禁用
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailMessage:
|
||||
"""邮件消息数据类"""
|
||||
id: str # 消息 ID
|
||||
subject: str # 主题
|
||||
sender: str # 发件人
|
||||
recipients: List[str] = field(default_factory=list) # 收件人列表
|
||||
body: str = "" # 正文内容
|
||||
body_preview: str = "" # 正文预览
|
||||
received_at: Optional[datetime] = None # 接收时间
|
||||
received_timestamp: int = 0 # 接收时间戳
|
||||
is_read: bool = False # 是否已读
|
||||
has_attachments: bool = False # 是否有附件
|
||||
raw_data: Optional[bytes] = None # 原始数据(用于调试)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"subject": self.subject,
|
||||
"sender": self.sender,
|
||||
"recipients": self.recipients,
|
||||
"body": self.body,
|
||||
"body_preview": self.body_preview,
|
||||
"received_at": self.received_at.isoformat() if self.received_at else None,
|
||||
"received_timestamp": self.received_timestamp,
|
||||
"is_read": self.is_read,
|
||||
"has_attachments": self.has_attachments,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenInfo:
|
||||
"""Token 信息数据类"""
|
||||
access_token: str
|
||||
expires_at: float # 过期时间戳
|
||||
token_type: str = "Bearer"
|
||||
scope: str = ""
|
||||
refresh_token: Optional[str] = None
|
||||
|
||||
def is_expired(self, buffer_seconds: int = 120) -> bool:
|
||||
"""检查 Token 是否已过期"""
|
||||
import time
|
||||
return time.time() >= (self.expires_at - buffer_seconds)
|
||||
|
||||
@classmethod
|
||||
def from_response(cls, data: Dict[str, Any], scope: str = "") -> "TokenInfo":
|
||||
"""从 API 响应创建"""
|
||||
import time
|
||||
return cls(
|
||||
access_token=data.get("access_token", ""),
|
||||
expires_at=time.time() + data.get("expires_in", 3600),
|
||||
token_type=data.get("token_type", "Bearer"),
|
||||
scope=scope or data.get("scope", ""),
|
||||
refresh_token=data.get("refresh_token"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderHealth:
|
||||
"""提供者健康状态"""
|
||||
provider_type: ProviderType
|
||||
status: ProviderStatus = ProviderStatus.HEALTHY
|
||||
failure_count: int = 0 # 连续失败次数
|
||||
last_success: Optional[datetime] = None # 最后成功时间
|
||||
last_failure: Optional[datetime] = None # 最后失败时间
|
||||
last_error: str = "" # 最后错误信息
|
||||
disabled_until: Optional[datetime] = None # 禁用截止时间
|
||||
|
||||
def record_success(self):
|
||||
"""记录成功"""
|
||||
self.status = ProviderStatus.HEALTHY
|
||||
self.failure_count = 0
|
||||
self.last_success = datetime.now()
|
||||
self.disabled_until = None
|
||||
|
||||
def record_failure(self, error: str):
|
||||
"""记录失败"""
|
||||
self.failure_count += 1
|
||||
self.last_failure = datetime.now()
|
||||
self.last_error = error
|
||||
|
||||
def should_disable(self, threshold: int = 3) -> bool:
|
||||
"""判断是否应该禁用"""
|
||||
return self.failure_count >= threshold
|
||||
|
||||
def is_disabled(self) -> bool:
|
||||
"""检查是否被禁用"""
|
||||
if self.disabled_until and datetime.now() < self.disabled_until:
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable(self, duration_seconds: int = 300):
|
||||
"""禁用提供者"""
|
||||
from datetime import timedelta
|
||||
self.status = ProviderStatus.DISABLED
|
||||
self.disabled_until = datetime.now() + timedelta(seconds=duration_seconds)
|
||||
|
||||
def enable(self):
|
||||
"""启用提供者"""
|
||||
self.status = ProviderStatus.HEALTHY
|
||||
self.disabled_until = None
|
||||
self.failure_count = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"provider_type": self.provider_type.value,
|
||||
"status": self.status.value,
|
||||
"failure_count": self.failure_count,
|
||||
"last_success": self.last_success.isoformat() if self.last_success else None,
|
||||
"last_failure": self.last_failure.isoformat() if self.last_failure else None,
|
||||
"last_error": self.last_error,
|
||||
"disabled_until": self.disabled_until.isoformat() if self.disabled_until else None,
|
||||
}
|
||||
228
src/services/outlook/email_parser.py
Normal file
228
src/services/outlook/email_parser.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
邮件解析和验证码提取
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from ...config.constants import (
|
||||
OTP_CODE_SIMPLE_PATTERN,
|
||||
OTP_CODE_SEMANTIC_PATTERN,
|
||||
OPENAI_EMAIL_SENDERS,
|
||||
OPENAI_VERIFICATION_KEYWORDS,
|
||||
)
|
||||
from .base import EmailMessage
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailParser:
|
||||
"""
|
||||
邮件解析器
|
||||
用于识别 OpenAI 验证邮件并提取验证码
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# 编译正则表达式
|
||||
self._simple_pattern = re.compile(OTP_CODE_SIMPLE_PATTERN)
|
||||
self._semantic_pattern = re.compile(OTP_CODE_SEMANTIC_PATTERN, re.IGNORECASE)
|
||||
|
||||
def is_openai_verification_email(
|
||||
self,
|
||||
email: EmailMessage,
|
||||
target_email: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
判断是否为 OpenAI 验证邮件
|
||||
|
||||
Args:
|
||||
email: 邮件对象
|
||||
target_email: 目标邮箱地址(用于验证收件人)
|
||||
|
||||
Returns:
|
||||
是否为 OpenAI 验证邮件
|
||||
"""
|
||||
sender = email.sender.lower()
|
||||
|
||||
# 1. 发件人必须是 OpenAI
|
||||
if not any(s in sender for s in OPENAI_EMAIL_SENDERS):
|
||||
logger.debug(f"邮件发件人非 OpenAI: {sender}")
|
||||
return False
|
||||
|
||||
# 2. 主题或正文包含验证关键词
|
||||
subject = email.subject.lower()
|
||||
body = email.body.lower()
|
||||
combined = f"{subject} {body}"
|
||||
|
||||
if not any(kw in combined for kw in OPENAI_VERIFICATION_KEYWORDS):
|
||||
logger.debug(f"邮件未包含验证关键词: {subject[:50]}")
|
||||
return False
|
||||
|
||||
# 3. 收件人检查已移除:别名邮件的 IMAP 头中收件人可能不匹配,只靠发件人+关键词判断
|
||||
logger.debug(f"识别为 OpenAI 验证邮件: {subject[:50]}")
|
||||
return True
|
||||
|
||||
def extract_verification_code(
|
||||
self,
|
||||
email: EmailMessage,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
从邮件中提取验证码
|
||||
|
||||
优先级:
|
||||
1. 从主题提取(6位数字)
|
||||
2. 从正文用语义正则提取(如 "code is 123456")
|
||||
3. 兜底:任意 6 位数字
|
||||
|
||||
Args:
|
||||
email: 邮件对象
|
||||
|
||||
Returns:
|
||||
验证码字符串,如果未找到返回 None
|
||||
"""
|
||||
# 1. 主题优先
|
||||
code = self._extract_from_subject(email.subject)
|
||||
if code:
|
||||
logger.debug(f"从主题提取验证码: {code}")
|
||||
return code
|
||||
|
||||
# 2. 正文语义匹配
|
||||
code = self._extract_semantic(email.body)
|
||||
if code:
|
||||
logger.debug(f"从正文语义提取验证码: {code}")
|
||||
return code
|
||||
|
||||
# 3. 兜底:正文任意 6 位数字
|
||||
code = self._extract_simple(email.body)
|
||||
if code:
|
||||
logger.debug(f"从正文兜底提取验证码: {code}")
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
def _extract_from_subject(self, subject: str) -> Optional[str]:
|
||||
"""从主题提取验证码"""
|
||||
match = self._simple_pattern.search(subject)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def _extract_semantic(self, body: str) -> Optional[str]:
|
||||
"""语义匹配提取验证码"""
|
||||
match = self._semantic_pattern.search(body)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def _extract_simple(self, body: str) -> Optional[str]:
|
||||
"""简单匹配提取验证码"""
|
||||
match = self._simple_pattern.search(body)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def find_verification_code_in_emails(
|
||||
self,
|
||||
emails: List[EmailMessage],
|
||||
target_email: Optional[str] = None,
|
||||
min_timestamp: int = 0,
|
||||
used_codes: Optional[set] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
从邮件列表中查找验证码
|
||||
|
||||
Args:
|
||||
emails: 邮件列表
|
||||
target_email: 目标邮箱地址
|
||||
min_timestamp: 最小时间戳(用于过滤旧邮件)
|
||||
used_codes: 已使用的验证码集合(用于去重)
|
||||
|
||||
Returns:
|
||||
验证码字符串,如果未找到返回 None
|
||||
"""
|
||||
used_codes = used_codes or set()
|
||||
|
||||
for email in emails:
|
||||
# 时间戳过滤
|
||||
if min_timestamp > 0 and email.received_timestamp > 0:
|
||||
if email.received_timestamp < min_timestamp:
|
||||
logger.debug(f"跳过旧邮件: {email.subject[:50]}")
|
||||
continue
|
||||
|
||||
# 检查是否是 OpenAI 验证邮件
|
||||
if not self.is_openai_verification_email(email, target_email):
|
||||
continue
|
||||
|
||||
# 提取验证码
|
||||
code = self.extract_verification_code(email)
|
||||
if code:
|
||||
# 去重检查
|
||||
if code in used_codes:
|
||||
logger.debug(f"跳过已使用的验证码: {code}")
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"[{target_email or 'unknown'}] 找到验证码: {code}, "
|
||||
f"邮件主题: {email.subject[:30]}"
|
||||
)
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
def filter_emails_by_sender(
|
||||
self,
|
||||
emails: List[EmailMessage],
|
||||
sender_patterns: List[str],
|
||||
) -> List[EmailMessage]:
|
||||
"""
|
||||
按发件人过滤邮件
|
||||
|
||||
Args:
|
||||
emails: 邮件列表
|
||||
sender_patterns: 发件人匹配模式列表
|
||||
|
||||
Returns:
|
||||
过滤后的邮件列表
|
||||
"""
|
||||
filtered = []
|
||||
for email in emails:
|
||||
sender = email.sender.lower()
|
||||
if any(pattern.lower() in sender for pattern in sender_patterns):
|
||||
filtered.append(email)
|
||||
return filtered
|
||||
|
||||
def filter_emails_by_subject(
|
||||
self,
|
||||
emails: List[EmailMessage],
|
||||
keywords: List[str],
|
||||
) -> List[EmailMessage]:
|
||||
"""
|
||||
按主题关键词过滤邮件
|
||||
|
||||
Args:
|
||||
emails: 邮件列表
|
||||
keywords: 关键词列表
|
||||
|
||||
Returns:
|
||||
过滤后的邮件列表
|
||||
"""
|
||||
filtered = []
|
||||
for email in emails:
|
||||
subject = email.subject.lower()
|
||||
if any(kw.lower() in subject for kw in keywords):
|
||||
filtered.append(email)
|
||||
return filtered
|
||||
|
||||
|
||||
# 全局解析器实例
|
||||
_parser: Optional[EmailParser] = None
|
||||
|
||||
|
||||
def get_email_parser() -> EmailParser:
|
||||
"""获取全局邮件解析器实例"""
|
||||
global _parser
|
||||
if _parser is None:
|
||||
_parser = EmailParser()
|
||||
return _parser
|
||||
312
src/services/outlook/health_checker.py
Normal file
312
src/services/outlook/health_checker.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
健康检查和故障切换管理
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from .base import ProviderType, ProviderHealth, ProviderStatus
|
||||
from .providers.base import OutlookProvider
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HealthChecker:
|
||||
"""
|
||||
健康检查管理器
|
||||
跟踪各提供者的健康状态,管理故障切换
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
failure_threshold: int = 3,
|
||||
disable_duration: int = 300,
|
||||
recovery_check_interval: int = 60,
|
||||
):
|
||||
"""
|
||||
初始化健康检查器
|
||||
|
||||
Args:
|
||||
failure_threshold: 连续失败次数阈值,超过后禁用
|
||||
disable_duration: 禁用时长(秒)
|
||||
recovery_check_interval: 恢复检查间隔(秒)
|
||||
"""
|
||||
self.failure_threshold = failure_threshold
|
||||
self.disable_duration = disable_duration
|
||||
self.recovery_check_interval = recovery_check_interval
|
||||
|
||||
# 提供者健康状态: ProviderType -> ProviderHealth
|
||||
self._health_status: Dict[ProviderType, ProviderHealth] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# 初始化所有提供者的健康状态
|
||||
for provider_type in ProviderType:
|
||||
self._health_status[provider_type] = ProviderHealth(
|
||||
provider_type=provider_type
|
||||
)
|
||||
|
||||
def get_health(self, provider_type: ProviderType) -> ProviderHealth:
|
||||
"""获取提供者的健康状态"""
|
||||
with self._lock:
|
||||
return self._health_status.get(provider_type, ProviderHealth(provider_type=provider_type))
|
||||
|
||||
def record_success(self, provider_type: ProviderType):
|
||||
"""记录成功操作"""
|
||||
with self._lock:
|
||||
health = self._health_status.get(provider_type)
|
||||
if health:
|
||||
health.record_success()
|
||||
logger.debug(f"{provider_type.value} 记录成功")
|
||||
|
||||
def record_failure(self, provider_type: ProviderType, error: str):
|
||||
"""记录失败操作"""
|
||||
with self._lock:
|
||||
health = self._health_status.get(provider_type)
|
||||
if health:
|
||||
health.record_failure(error)
|
||||
|
||||
# 检查是否需要禁用
|
||||
if health.should_disable(self.failure_threshold):
|
||||
health.disable(self.disable_duration)
|
||||
logger.warning(
|
||||
f"{provider_type.value} 已禁用 {self.disable_duration} 秒,"
|
||||
f"原因: {error}"
|
||||
)
|
||||
|
||||
def is_available(self, provider_type: ProviderType) -> bool:
|
||||
"""
|
||||
检查提供者是否可用
|
||||
|
||||
Args:
|
||||
provider_type: 提供者类型
|
||||
|
||||
Returns:
|
||||
是否可用
|
||||
"""
|
||||
health = self.get_health(provider_type)
|
||||
|
||||
# 检查是否被禁用
|
||||
if health.is_disabled():
|
||||
remaining = (health.disabled_until - datetime.now()).total_seconds()
|
||||
logger.debug(
|
||||
f"{provider_type.value} 已被禁用,剩余 {int(remaining)} 秒"
|
||||
)
|
||||
return False
|
||||
|
||||
return health.status != ProviderStatus.DISABLED
|
||||
|
||||
def get_available_providers(
|
||||
self,
|
||||
priority_order: Optional[List[ProviderType]] = None,
|
||||
) -> List[ProviderType]:
|
||||
"""
|
||||
获取可用的提供者列表
|
||||
|
||||
Args:
|
||||
priority_order: 优先级顺序,默认为 [IMAP_NEW, IMAP_OLD, GRAPH_API]
|
||||
|
||||
Returns:
|
||||
可用的提供者列表
|
||||
"""
|
||||
if priority_order is None:
|
||||
priority_order = [
|
||||
ProviderType.IMAP_NEW,
|
||||
ProviderType.IMAP_OLD,
|
||||
ProviderType.GRAPH_API,
|
||||
]
|
||||
|
||||
available = []
|
||||
for provider_type in priority_order:
|
||||
if self.is_available(provider_type):
|
||||
available.append(provider_type)
|
||||
|
||||
return available
|
||||
|
||||
def get_next_available_provider(
|
||||
self,
|
||||
priority_order: Optional[List[ProviderType]] = None,
|
||||
) -> Optional[ProviderType]:
|
||||
"""
|
||||
获取下一个可用的提供者
|
||||
|
||||
Args:
|
||||
priority_order: 优先级顺序
|
||||
|
||||
Returns:
|
||||
可用的提供者类型,如果没有返回 None
|
||||
"""
|
||||
available = self.get_available_providers(priority_order)
|
||||
return available[0] if available else None
|
||||
|
||||
def force_disable(self, provider_type: ProviderType, duration: Optional[int] = None):
|
||||
"""
|
||||
强制禁用提供者
|
||||
|
||||
Args:
|
||||
provider_type: 提供者类型
|
||||
duration: 禁用时长(秒),默认使用配置值
|
||||
"""
|
||||
with self._lock:
|
||||
health = self._health_status.get(provider_type)
|
||||
if health:
|
||||
health.disable(duration or self.disable_duration)
|
||||
logger.warning(f"{provider_type.value} 已强制禁用")
|
||||
|
||||
def force_enable(self, provider_type: ProviderType):
|
||||
"""
|
||||
强制启用提供者
|
||||
|
||||
Args:
|
||||
provider_type: 提供者类型
|
||||
"""
|
||||
with self._lock:
|
||||
health = self._health_status.get(provider_type)
|
||||
if health:
|
||||
health.enable()
|
||||
logger.info(f"{provider_type.value} 已启用")
|
||||
|
||||
def get_all_health_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取所有提供者的健康状态
|
||||
|
||||
Returns:
|
||||
健康状态字典
|
||||
"""
|
||||
with self._lock:
|
||||
return {
|
||||
provider_type.value: health.to_dict()
|
||||
for provider_type, health in self._health_status.items()
|
||||
}
|
||||
|
||||
def check_and_recover(self):
|
||||
"""
|
||||
检查并恢复被禁用的提供者
|
||||
|
||||
如果禁用时间已过,自动恢复提供者
|
||||
"""
|
||||
with self._lock:
|
||||
for provider_type, health in self._health_status.items():
|
||||
if health.is_disabled():
|
||||
# 检查是否可以恢复
|
||||
if health.disabled_until and datetime.now() >= health.disabled_until:
|
||||
health.enable()
|
||||
logger.info(f"{provider_type.value} 已自动恢复")
|
||||
|
||||
def reset_all(self):
|
||||
"""重置所有提供者的健康状态"""
|
||||
with self._lock:
|
||||
for provider_type in ProviderType:
|
||||
self._health_status[provider_type] = ProviderHealth(
|
||||
provider_type=provider_type
|
||||
)
|
||||
logger.info("已重置所有提供者的健康状态")
|
||||
|
||||
|
||||
class FailoverManager:
|
||||
"""
|
||||
故障切换管理器
|
||||
管理提供者之间的自动切换
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
health_checker: HealthChecker,
|
||||
priority_order: Optional[List[ProviderType]] = None,
|
||||
):
|
||||
"""
|
||||
初始化故障切换管理器
|
||||
|
||||
Args:
|
||||
health_checker: 健康检查器
|
||||
priority_order: 提供者优先级顺序
|
||||
"""
|
||||
self.health_checker = health_checker
|
||||
self.priority_order = priority_order or [
|
||||
ProviderType.IMAP_NEW,
|
||||
ProviderType.IMAP_OLD,
|
||||
ProviderType.GRAPH_API,
|
||||
]
|
||||
|
||||
# 当前使用的提供者索引
|
||||
self._current_index = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get_current_provider(self) -> Optional[ProviderType]:
|
||||
"""
|
||||
获取当前提供者
|
||||
|
||||
Returns:
|
||||
当前提供者类型,如果没有可用的返回 None
|
||||
"""
|
||||
available = self.health_checker.get_available_providers(self.priority_order)
|
||||
if not available:
|
||||
return None
|
||||
|
||||
with self._lock:
|
||||
# 尝试使用当前索引
|
||||
if self._current_index < len(available):
|
||||
return available[self._current_index]
|
||||
return available[0]
|
||||
|
||||
def switch_to_next(self) -> Optional[ProviderType]:
|
||||
"""
|
||||
切换到下一个提供者
|
||||
|
||||
Returns:
|
||||
下一个提供者类型,如果没有可用的返回 None
|
||||
"""
|
||||
available = self.health_checker.get_available_providers(self.priority_order)
|
||||
if not available:
|
||||
return None
|
||||
|
||||
with self._lock:
|
||||
self._current_index = (self._current_index + 1) % len(available)
|
||||
next_provider = available[self._current_index]
|
||||
logger.info(f"切换到提供者: {next_provider.value}")
|
||||
return next_provider
|
||||
|
||||
def on_provider_success(self, provider_type: ProviderType):
|
||||
"""
|
||||
提供者成功时调用
|
||||
|
||||
Args:
|
||||
provider_type: 提供者类型
|
||||
"""
|
||||
self.health_checker.record_success(provider_type)
|
||||
|
||||
# 重置索引到成功的提供者
|
||||
with self._lock:
|
||||
available = self.health_checker.get_available_providers(self.priority_order)
|
||||
if provider_type in available:
|
||||
self._current_index = available.index(provider_type)
|
||||
|
||||
def on_provider_failure(self, provider_type: ProviderType, error: str):
|
||||
"""
|
||||
提供者失败时调用
|
||||
|
||||
Args:
|
||||
provider_type: 提供者类型
|
||||
error: 错误信息
|
||||
"""
|
||||
self.health_checker.record_failure(provider_type, error)
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取故障切换状态
|
||||
|
||||
Returns:
|
||||
状态字典
|
||||
"""
|
||||
current = self.get_current_provider()
|
||||
return {
|
||||
"current_provider": current.value if current else None,
|
||||
"priority_order": [p.value for p in self.priority_order],
|
||||
"available_providers": [
|
||||
p.value for p in self.health_checker.get_available_providers(self.priority_order)
|
||||
],
|
||||
"health_status": self.health_checker.get_all_health_status(),
|
||||
}
|
||||
29
src/services/outlook/providers/__init__.py
Normal file
29
src/services/outlook/providers/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Outlook 提供者模块
|
||||
"""
|
||||
|
||||
from .base import OutlookProvider, ProviderConfig
|
||||
from .imap_old import IMAPOldProvider
|
||||
from .imap_new import IMAPNewProvider
|
||||
from .graph_api import GraphAPIProvider
|
||||
|
||||
__all__ = [
|
||||
'OutlookProvider',
|
||||
'ProviderConfig',
|
||||
'IMAPOldProvider',
|
||||
'IMAPNewProvider',
|
||||
'GraphAPIProvider',
|
||||
]
|
||||
|
||||
|
||||
# 提供者注册表
|
||||
PROVIDER_REGISTRY = {
|
||||
'imap_old': IMAPOldProvider,
|
||||
'imap_new': IMAPNewProvider,
|
||||
'graph_api': GraphAPIProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider_class(provider_type: str):
|
||||
"""获取提供者类"""
|
||||
return PROVIDER_REGISTRY.get(provider_type)
|
||||
180
src/services/outlook/providers/base.py
Normal file
180
src/services/outlook/providers/base.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Outlook 提供者抽象基类
|
||||
"""
|
||||
|
||||
import abc
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from ..base import ProviderType, EmailMessage, ProviderHealth, ProviderStatus
|
||||
from ..account import OutlookAccount
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderConfig:
|
||||
"""提供者配置"""
|
||||
timeout: int = 30
|
||||
max_retries: int = 3
|
||||
proxy_url: Optional[str] = None
|
||||
|
||||
# 健康检查配置
|
||||
health_failure_threshold: int = 3
|
||||
health_disable_duration: int = 300 # 秒
|
||||
|
||||
|
||||
class OutlookProvider(abc.ABC):
|
||||
"""
|
||||
Outlook 提供者抽象基类
|
||||
定义所有提供者必须实现的接口
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
account: OutlookAccount,
|
||||
config: Optional[ProviderConfig] = None,
|
||||
):
|
||||
"""
|
||||
初始化提供者
|
||||
|
||||
Args:
|
||||
account: Outlook 账户
|
||||
config: 提供者配置
|
||||
"""
|
||||
self.account = account
|
||||
self.config = config or ProviderConfig()
|
||||
|
||||
# 健康状态
|
||||
self._health = ProviderHealth(provider_type=self.provider_type)
|
||||
|
||||
# 连接状态
|
||||
self._connected = False
|
||||
self._last_error: Optional[str] = None
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def provider_type(self) -> ProviderType:
|
||||
"""获取提供者类型"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def health(self) -> ProviderHealth:
|
||||
"""获取健康状态"""
|
||||
return self._health
|
||||
|
||||
@property
|
||||
def is_healthy(self) -> bool:
|
||||
"""检查是否健康"""
|
||||
return (
|
||||
self._health.status == ProviderStatus.HEALTHY
|
||||
and not self._health.is_disabled()
|
||||
)
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""检查是否已连接"""
|
||||
return self._connected
|
||||
|
||||
@abc.abstractmethod
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
连接到服务
|
||||
|
||||
Returns:
|
||||
是否连接成功
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def disconnect(self):
|
||||
"""断开连接"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_recent_emails(
|
||||
self,
|
||||
count: int = 20,
|
||||
only_unseen: bool = True,
|
||||
) -> List[EmailMessage]:
|
||||
"""
|
||||
获取最近的邮件
|
||||
|
||||
Args:
|
||||
count: 获取数量
|
||||
only_unseen: 是否只获取未读
|
||||
|
||||
Returns:
|
||||
邮件列表
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def test_connection(self) -> bool:
|
||||
"""
|
||||
测试连接是否正常
|
||||
|
||||
Returns:
|
||||
连接是否正常
|
||||
"""
|
||||
pass
|
||||
|
||||
def record_success(self):
|
||||
"""记录成功操作"""
|
||||
self._health.record_success()
|
||||
self._last_error = None
|
||||
logger.debug(f"[{self.account.email}] {self.provider_type.value} 操作成功")
|
||||
|
||||
def record_failure(self, error: str):
|
||||
"""记录失败操作"""
|
||||
self._health.record_failure(error)
|
||||
self._last_error = error
|
||||
|
||||
# 检查是否需要禁用
|
||||
if self._health.should_disable(self.config.health_failure_threshold):
|
||||
self._health.disable(self.config.health_disable_duration)
|
||||
logger.warning(
|
||||
f"[{self.account.email}] {self.provider_type.value} 已禁用 "
|
||||
f"{self.config.health_disable_duration} 秒,原因: {error}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[{self.account.email}] {self.provider_type.value} 操作失败 "
|
||||
f"({self._health.failure_count}/{self.config.health_failure_threshold}): {error}"
|
||||
)
|
||||
|
||||
def check_health(self) -> bool:
|
||||
"""
|
||||
检查健康状态
|
||||
|
||||
Returns:
|
||||
是否健康可用
|
||||
"""
|
||||
# 检查是否被禁用
|
||||
if self._health.is_disabled():
|
||||
logger.debug(
|
||||
f"[{self.account.email}] {self.provider_type.value} 已被禁用,"
|
||||
f"将在 {self._health.disabled_until} 后恢复"
|
||||
)
|
||||
return False
|
||||
|
||||
return self._health.status in (ProviderStatus.HEALTHY, ProviderStatus.DEGRADED)
|
||||
|
||||
def __enter__(self):
|
||||
"""上下文管理器入口"""
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""上下文管理器出口"""
|
||||
self.disconnect()
|
||||
return False
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""字符串表示"""
|
||||
return f"{self.__class__.__name__}({self.account.email})"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
250
src/services/outlook/providers/graph_api.py
Normal file
250
src/services/outlook/providers/graph_api.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
Graph API 提供者
|
||||
使用 Microsoft Graph REST API
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from curl_cffi import requests as _requests
|
||||
|
||||
from ..base import ProviderType, EmailMessage
|
||||
from ..account import OutlookAccount
|
||||
from ..token_manager import TokenManager
|
||||
from .base import OutlookProvider, ProviderConfig
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GraphAPIProvider(OutlookProvider):
|
||||
"""
|
||||
Graph API 提供者
|
||||
使用 Microsoft Graph REST API 获取邮件
|
||||
需要 graph.microsoft.com/.default scope
|
||||
"""
|
||||
|
||||
# Graph API 端点
|
||||
GRAPH_API_BASE = "https://graph.microsoft.com/v1.0"
|
||||
MESSAGES_ENDPOINT = "/me/mailFolders/inbox/messages"
|
||||
|
||||
@property
|
||||
def provider_type(self) -> ProviderType:
|
||||
return ProviderType.GRAPH_API
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
account: OutlookAccount,
|
||||
config: Optional[ProviderConfig] = None,
|
||||
):
|
||||
super().__init__(account, config)
|
||||
|
||||
# Token 管理器
|
||||
self._token_manager: Optional[TokenManager] = None
|
||||
|
||||
# 注意:Graph API 必须使用 OAuth2
|
||||
if not account.has_oauth():
|
||||
logger.warning(
|
||||
f"[{self.account.email}] Graph API 提供者需要 OAuth2 配置 "
|
||||
f"(client_id + refresh_token)"
|
||||
)
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
验证连接(获取 Token)
|
||||
|
||||
Returns:
|
||||
是否连接成功
|
||||
"""
|
||||
if not self.account.has_oauth():
|
||||
error = "Graph API 需要 OAuth2 配置"
|
||||
self.record_failure(error)
|
||||
logger.error(f"[{self.account.email}] {error}")
|
||||
return False
|
||||
|
||||
if not self._token_manager:
|
||||
self._token_manager = TokenManager(
|
||||
self.account,
|
||||
ProviderType.GRAPH_API,
|
||||
self.config.proxy_url,
|
||||
self.config.timeout,
|
||||
)
|
||||
|
||||
# 尝试获取 Token
|
||||
token = self._token_manager.get_access_token()
|
||||
if token:
|
||||
self._connected = True
|
||||
self.record_success()
|
||||
logger.info(f"[{self.account.email}] Graph API 连接成功")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""断开连接(清除状态)"""
|
||||
self._connected = False
|
||||
|
||||
def get_recent_emails(
|
||||
self,
|
||||
count: int = 20,
|
||||
only_unseen: bool = True,
|
||||
) -> List[EmailMessage]:
|
||||
"""
|
||||
获取最近的邮件
|
||||
|
||||
Args:
|
||||
count: 获取数量
|
||||
only_unseen: 是否只获取未读
|
||||
|
||||
Returns:
|
||||
邮件列表
|
||||
"""
|
||||
if not self._connected:
|
||||
if not self.connect():
|
||||
return []
|
||||
|
||||
try:
|
||||
# 获取 Access Token
|
||||
token = self._token_manager.get_access_token()
|
||||
if not token:
|
||||
self.record_failure("无法获取 Access Token")
|
||||
return []
|
||||
|
||||
# 构建 API 请求
|
||||
url = f"{self.GRAPH_API_BASE}{self.MESSAGES_ENDPOINT}"
|
||||
|
||||
params = {
|
||||
"$top": count,
|
||||
"$select": "id,subject,from,toRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,body",
|
||||
"$orderby": "receivedDateTime desc",
|
||||
}
|
||||
|
||||
# 只获取未读邮件
|
||||
if only_unseen:
|
||||
params["$filter"] = "isRead eq false"
|
||||
|
||||
# 构建代理配置
|
||||
proxies = None
|
||||
if self.config.proxy_url:
|
||||
proxies = {"http": self.config.proxy_url, "https": self.config.proxy_url}
|
||||
|
||||
# 发送请求(curl_cffi 自动对 params 进行 URL 编码)
|
||||
resp = _requests.get(
|
||||
url,
|
||||
params=params,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
"Prefer": "outlook.body-content-type='text'",
|
||||
},
|
||||
proxies=proxies,
|
||||
timeout=self.config.timeout,
|
||||
impersonate="chrome110",
|
||||
)
|
||||
|
||||
if resp.status_code == 401:
|
||||
# Token 无 Graph 权限(client_id 未授权),清除缓存但不记录健康失败
|
||||
# 避免因权限不足导致健康检查器禁用该提供者,影响其他账户
|
||||
if self._token_manager:
|
||||
self._token_manager.clear_cache()
|
||||
self._connected = False
|
||||
logger.warning(f"[{self.account.email}] Graph API 返回 401,client_id 可能无 Graph 权限,跳过")
|
||||
return []
|
||||
|
||||
if resp.status_code != 200:
|
||||
error_body = resp.text[:200]
|
||||
self.record_failure(f"HTTP {resp.status_code}: {error_body}")
|
||||
logger.error(f"[{self.account.email}] Graph API 请求失败: HTTP {resp.status_code}")
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
|
||||
# 解析邮件
|
||||
messages = data.get("value", [])
|
||||
emails = []
|
||||
|
||||
for msg in messages:
|
||||
try:
|
||||
email_msg = self._parse_graph_message(msg)
|
||||
if email_msg:
|
||||
emails.append(email_msg)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.account.email}] 解析 Graph API 邮件失败: {e}")
|
||||
|
||||
self.record_success()
|
||||
return emails
|
||||
|
||||
except Exception as e:
|
||||
self.record_failure(str(e))
|
||||
logger.error(f"[{self.account.email}] Graph API 获取邮件失败: {e}")
|
||||
return []
|
||||
|
||||
def _parse_graph_message(self, msg: dict) -> Optional[EmailMessage]:
|
||||
"""
|
||||
解析 Graph API 消息
|
||||
|
||||
Args:
|
||||
msg: Graph API 消息对象
|
||||
|
||||
Returns:
|
||||
EmailMessage 对象
|
||||
"""
|
||||
# 解析发件人
|
||||
from_info = msg.get("from", {})
|
||||
sender_info = from_info.get("emailAddress", {})
|
||||
sender = sender_info.get("address", "")
|
||||
|
||||
# 解析收件人
|
||||
recipients = []
|
||||
for recipient in msg.get("toRecipients", []):
|
||||
addr_info = recipient.get("emailAddress", {})
|
||||
addr = addr_info.get("address", "")
|
||||
if addr:
|
||||
recipients.append(addr)
|
||||
|
||||
# 解析日期
|
||||
received_at = None
|
||||
received_timestamp = 0
|
||||
try:
|
||||
date_str = msg.get("receivedDateTime", "")
|
||||
if date_str:
|
||||
# ISO 8601 格式
|
||||
received_at = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||
received_timestamp = int(received_at.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 获取正文
|
||||
body_info = msg.get("body", {})
|
||||
body = body_info.get("content", "")
|
||||
body_preview = msg.get("bodyPreview", "")
|
||||
|
||||
return EmailMessage(
|
||||
id=msg.get("id", ""),
|
||||
subject=msg.get("subject", ""),
|
||||
sender=sender,
|
||||
recipients=recipients,
|
||||
body=body,
|
||||
body_preview=body_preview,
|
||||
received_at=received_at,
|
||||
received_timestamp=received_timestamp,
|
||||
is_read=msg.get("isRead", False),
|
||||
has_attachments=msg.get("hasAttachments", False),
|
||||
)
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""
|
||||
测试 Graph API 连接
|
||||
|
||||
Returns:
|
||||
连接是否正常
|
||||
"""
|
||||
try:
|
||||
# 尝试获取一封邮件来测试连接
|
||||
emails = self.get_recent_emails(count=1, only_unseen=False)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.account.email}] Graph API 连接测试失败: {e}")
|
||||
return False
|
||||
231
src/services/outlook/providers/imap_new.py
Normal file
231
src/services/outlook/providers/imap_new.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
新版 IMAP 提供者
|
||||
使用 outlook.live.com 服务器和 login.microsoftonline.com/consumers Token 端点
|
||||
"""
|
||||
|
||||
import email
|
||||
import imaplib
|
||||
import logging
|
||||
from email.header import decode_header
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from ..base import ProviderType, EmailMessage
|
||||
from ..account import OutlookAccount
|
||||
from ..token_manager import TokenManager
|
||||
from .base import OutlookProvider, ProviderConfig
|
||||
from .imap_old import IMAPOldProvider
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IMAPNewProvider(OutlookProvider):
|
||||
"""
|
||||
新版 IMAP 提供者
|
||||
使用 outlook.live.com:993 和 login.microsoftonline.com/consumers Token 端点
|
||||
需要 IMAP.AccessAsUser.All scope
|
||||
"""
|
||||
|
||||
# IMAP 服务器配置
|
||||
IMAP_HOST = "outlook.live.com"
|
||||
IMAP_PORT = 993
|
||||
|
||||
@property
|
||||
def provider_type(self) -> ProviderType:
|
||||
return ProviderType.IMAP_NEW
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
account: OutlookAccount,
|
||||
config: Optional[ProviderConfig] = None,
|
||||
):
|
||||
super().__init__(account, config)
|
||||
|
||||
# IMAP 连接
|
||||
self._conn: Optional[imaplib.IMAP4_SSL] = None
|
||||
|
||||
# Token 管理器
|
||||
self._token_manager: Optional[TokenManager] = None
|
||||
|
||||
# 注意:新版 IMAP 必须使用 OAuth2
|
||||
if not account.has_oauth():
|
||||
logger.warning(
|
||||
f"[{self.account.email}] 新版 IMAP 提供者需要 OAuth2 配置 "
|
||||
f"(client_id + refresh_token)"
|
||||
)
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
连接到 IMAP 服务器
|
||||
|
||||
Returns:
|
||||
是否连接成功
|
||||
"""
|
||||
if self._connected and self._conn:
|
||||
try:
|
||||
self._conn.noop()
|
||||
return True
|
||||
except Exception:
|
||||
self.disconnect()
|
||||
|
||||
# 新版 IMAP 必须使用 OAuth2,无 OAuth 时静默跳过,不记录健康失败
|
||||
if not self.account.has_oauth():
|
||||
logger.debug(f"[{self.account.email}] 跳过 IMAP_NEW(无 OAuth)")
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.debug(f"[{self.account.email}] 正在连接 IMAP ({self.IMAP_HOST})...")
|
||||
|
||||
# 创建连接
|
||||
self._conn = imaplib.IMAP4_SSL(
|
||||
self.IMAP_HOST,
|
||||
self.IMAP_PORT,
|
||||
timeout=self.config.timeout,
|
||||
)
|
||||
|
||||
# XOAUTH2 认证
|
||||
if self._authenticate_xoauth2():
|
||||
self._connected = True
|
||||
self.record_success()
|
||||
logger.info(f"[{self.account.email}] 新版 IMAP 连接成功 (XOAUTH2)")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.disconnect()
|
||||
self.record_failure(str(e))
|
||||
logger.error(f"[{self.account.email}] 新版 IMAP 连接失败: {e}")
|
||||
return False
|
||||
|
||||
def _authenticate_xoauth2(self) -> bool:
|
||||
"""
|
||||
使用 XOAUTH2 认证
|
||||
|
||||
Returns:
|
||||
是否认证成功
|
||||
"""
|
||||
if not self._token_manager:
|
||||
self._token_manager = TokenManager(
|
||||
self.account,
|
||||
ProviderType.IMAP_NEW,
|
||||
self.config.proxy_url,
|
||||
self.config.timeout,
|
||||
)
|
||||
|
||||
# 获取 Access Token
|
||||
token = self._token_manager.get_access_token()
|
||||
if not token:
|
||||
logger.error(f"[{self.account.email}] 获取 IMAP Token 失败")
|
||||
return False
|
||||
|
||||
try:
|
||||
# 构建 XOAUTH2 认证字符串
|
||||
auth_string = f"user={self.account.email}\x01auth=Bearer {token}\x01\x01"
|
||||
self._conn.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8"))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.account.email}] XOAUTH2 认证异常: {e}")
|
||||
# 清除缓存的 Token
|
||||
self._token_manager.clear_cache()
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""断开 IMAP 连接"""
|
||||
if self._conn:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
self._conn = None
|
||||
|
||||
self._connected = False
|
||||
|
||||
def get_recent_emails(
|
||||
self,
|
||||
count: int = 20,
|
||||
only_unseen: bool = True,
|
||||
) -> List[EmailMessage]:
|
||||
"""
|
||||
获取最近的邮件
|
||||
|
||||
Args:
|
||||
count: 获取数量
|
||||
only_unseen: 是否只获取未读
|
||||
|
||||
Returns:
|
||||
邮件列表
|
||||
"""
|
||||
if not self._connected:
|
||||
if not self.connect():
|
||||
return []
|
||||
|
||||
try:
|
||||
# 选择收件箱
|
||||
self._conn.select("INBOX", readonly=True)
|
||||
|
||||
# 搜索邮件
|
||||
flag = "UNSEEN" if only_unseen else "ALL"
|
||||
status, data = self._conn.search(None, flag)
|
||||
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return []
|
||||
|
||||
# 获取最新的邮件 ID
|
||||
ids = data[0].split()
|
||||
recent_ids = ids[-count:][::-1]
|
||||
|
||||
emails = []
|
||||
for msg_id in recent_ids:
|
||||
try:
|
||||
email_msg = self._fetch_email(msg_id)
|
||||
if email_msg:
|
||||
emails.append(email_msg)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.account.email}] 解析邮件失败 (ID: {msg_id}): {e}")
|
||||
|
||||
return emails
|
||||
|
||||
except Exception as e:
|
||||
self.record_failure(str(e))
|
||||
logger.error(f"[{self.account.email}] 获取邮件失败: {e}")
|
||||
return []
|
||||
|
||||
def _fetch_email(self, msg_id: bytes) -> Optional[EmailMessage]:
|
||||
"""获取并解析单封邮件"""
|
||||
status, data = self._conn.fetch(msg_id, "(RFC822)")
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return None
|
||||
|
||||
raw = b""
|
||||
for part in data:
|
||||
if isinstance(part, tuple) and len(part) > 1:
|
||||
raw = part[1]
|
||||
break
|
||||
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
return self._parse_email(raw)
|
||||
|
||||
@staticmethod
|
||||
def _parse_email(raw: bytes) -> EmailMessage:
|
||||
"""解析原始邮件"""
|
||||
# 使用旧版提供者的解析方法
|
||||
return IMAPOldProvider._parse_email(raw)
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""测试 IMAP 连接"""
|
||||
try:
|
||||
with self:
|
||||
self._conn.select("INBOX", readonly=True)
|
||||
self._conn.search(None, "ALL")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.account.email}] 新版 IMAP 连接测试失败: {e}")
|
||||
return False
|
||||
345
src/services/outlook/providers/imap_old.py
Normal file
345
src/services/outlook/providers/imap_old.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
旧版 IMAP 提供者
|
||||
使用 outlook.office365.com 服务器和 login.live.com Token 端点
|
||||
"""
|
||||
|
||||
import email
|
||||
import imaplib
|
||||
import logging
|
||||
from email.header import decode_header
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from ..base import ProviderType, EmailMessage
|
||||
from ..account import OutlookAccount
|
||||
from ..token_manager import TokenManager
|
||||
from .base import OutlookProvider, ProviderConfig
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IMAPOldProvider(OutlookProvider):
|
||||
"""
|
||||
旧版 IMAP 提供者
|
||||
使用 outlook.office365.com:993 和 login.live.com Token 端点
|
||||
"""
|
||||
|
||||
# IMAP 服务器配置
|
||||
IMAP_HOST = "outlook.office365.com"
|
||||
IMAP_PORT = 993
|
||||
|
||||
@property
|
||||
def provider_type(self) -> ProviderType:
|
||||
return ProviderType.IMAP_OLD
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
account: OutlookAccount,
|
||||
config: Optional[ProviderConfig] = None,
|
||||
):
|
||||
super().__init__(account, config)
|
||||
|
||||
# IMAP 连接
|
||||
self._conn: Optional[imaplib.IMAP4_SSL] = None
|
||||
|
||||
# Token 管理器
|
||||
self._token_manager: Optional[TokenManager] = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
连接到 IMAP 服务器
|
||||
|
||||
Returns:
|
||||
是否连接成功
|
||||
"""
|
||||
if self._connected and self._conn:
|
||||
# 检查现有连接
|
||||
try:
|
||||
self._conn.noop()
|
||||
return True
|
||||
except Exception:
|
||||
self.disconnect()
|
||||
|
||||
try:
|
||||
logger.debug(f"[{self.account.email}] 正在连接 IMAP ({self.IMAP_HOST})...")
|
||||
|
||||
# 创建连接
|
||||
self._conn = imaplib.IMAP4_SSL(
|
||||
self.IMAP_HOST,
|
||||
self.IMAP_PORT,
|
||||
timeout=self.config.timeout,
|
||||
)
|
||||
|
||||
# 尝试 XOAUTH2 认证
|
||||
if self.account.has_oauth():
|
||||
if self._authenticate_xoauth2():
|
||||
self._connected = True
|
||||
self.record_success()
|
||||
logger.info(f"[{self.account.email}] IMAP 连接成功 (XOAUTH2)")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"[{self.account.email}] XOAUTH2 认证失败,尝试密码认证")
|
||||
|
||||
# 密码认证
|
||||
if self.account.password:
|
||||
self._conn.login(self.account.email, self.account.password)
|
||||
self._connected = True
|
||||
self.record_success()
|
||||
logger.info(f"[{self.account.email}] IMAP 连接成功 (密码认证)")
|
||||
return True
|
||||
|
||||
raise ValueError("没有可用的认证方式")
|
||||
|
||||
except Exception as e:
|
||||
self.disconnect()
|
||||
self.record_failure(str(e))
|
||||
logger.error(f"[{self.account.email}] IMAP 连接失败: {e}")
|
||||
return False
|
||||
|
||||
def _authenticate_xoauth2(self) -> bool:
|
||||
"""
|
||||
使用 XOAUTH2 认证
|
||||
|
||||
Returns:
|
||||
是否认证成功
|
||||
"""
|
||||
if not self._token_manager:
|
||||
self._token_manager = TokenManager(
|
||||
self.account,
|
||||
ProviderType.IMAP_OLD,
|
||||
self.config.proxy_url,
|
||||
self.config.timeout,
|
||||
)
|
||||
|
||||
# 获取 Access Token
|
||||
token = self._token_manager.get_access_token()
|
||||
if not token:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 构建 XOAUTH2 认证字符串
|
||||
auth_string = f"user={self.account.email}\x01auth=Bearer {token}\x01\x01"
|
||||
self._conn.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8"))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"[{self.account.email}] XOAUTH2 认证异常: {e}")
|
||||
# 清除缓存的 Token
|
||||
self._token_manager.clear_cache()
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""断开 IMAP 连接"""
|
||||
if self._conn:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
self._conn = None
|
||||
|
||||
self._connected = False
|
||||
|
||||
def get_recent_emails(
|
||||
self,
|
||||
count: int = 20,
|
||||
only_unseen: bool = True,
|
||||
) -> List[EmailMessage]:
|
||||
"""
|
||||
获取最近的邮件
|
||||
|
||||
Args:
|
||||
count: 获取数量
|
||||
only_unseen: 是否只获取未读
|
||||
|
||||
Returns:
|
||||
邮件列表
|
||||
"""
|
||||
if not self._connected:
|
||||
if not self.connect():
|
||||
return []
|
||||
|
||||
try:
|
||||
# 选择收件箱
|
||||
self._conn.select("INBOX", readonly=True)
|
||||
|
||||
# 搜索邮件
|
||||
flag = "UNSEEN" if only_unseen else "ALL"
|
||||
status, data = self._conn.search(None, flag)
|
||||
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return []
|
||||
|
||||
# 获取最新的邮件 ID
|
||||
ids = data[0].split()
|
||||
recent_ids = ids[-count:][::-1] # 倒序,最新的在前
|
||||
|
||||
emails = []
|
||||
for msg_id in recent_ids:
|
||||
try:
|
||||
email_msg = self._fetch_email(msg_id)
|
||||
if email_msg:
|
||||
emails.append(email_msg)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.account.email}] 解析邮件失败 (ID: {msg_id}): {e}")
|
||||
|
||||
return emails
|
||||
|
||||
except Exception as e:
|
||||
self.record_failure(str(e))
|
||||
logger.error(f"[{self.account.email}] 获取邮件失败: {e}")
|
||||
return []
|
||||
|
||||
def _fetch_email(self, msg_id: bytes) -> Optional[EmailMessage]:
|
||||
"""
|
||||
获取并解析单封邮件
|
||||
|
||||
Args:
|
||||
msg_id: 邮件 ID
|
||||
|
||||
Returns:
|
||||
EmailMessage 对象,失败返回 None
|
||||
"""
|
||||
status, data = self._conn.fetch(msg_id, "(RFC822)")
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return None
|
||||
|
||||
# 获取原始邮件内容
|
||||
raw = b""
|
||||
for part in data:
|
||||
if isinstance(part, tuple) and len(part) > 1:
|
||||
raw = part[1]
|
||||
break
|
||||
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
return self._parse_email(raw)
|
||||
|
||||
@staticmethod
|
||||
def _parse_email(raw: bytes) -> EmailMessage:
|
||||
"""
|
||||
解析原始邮件
|
||||
|
||||
Args:
|
||||
raw: 原始邮件数据
|
||||
|
||||
Returns:
|
||||
EmailMessage 对象
|
||||
"""
|
||||
# 移除 BOM
|
||||
if raw.startswith(b"\xef\xbb\xbf"):
|
||||
raw = raw[3:]
|
||||
|
||||
msg = email.message_from_bytes(raw)
|
||||
|
||||
# 解析邮件头
|
||||
subject = IMAPOldProvider._decode_header(msg.get("Subject", ""))
|
||||
sender = IMAPOldProvider._decode_header(msg.get("From", ""))
|
||||
to = IMAPOldProvider._decode_header(msg.get("To", ""))
|
||||
delivered_to = IMAPOldProvider._decode_header(msg.get("Delivered-To", ""))
|
||||
x_original_to = IMAPOldProvider._decode_header(msg.get("X-Original-To", ""))
|
||||
date_str = IMAPOldProvider._decode_header(msg.get("Date", ""))
|
||||
|
||||
# 提取正文
|
||||
body = IMAPOldProvider._extract_body(msg)
|
||||
|
||||
# 解析日期
|
||||
received_timestamp = 0
|
||||
received_at = None
|
||||
try:
|
||||
if date_str:
|
||||
received_at = parsedate_to_datetime(date_str)
|
||||
received_timestamp = int(received_at.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 构建收件人列表
|
||||
recipients = [r for r in [to, delivered_to, x_original_to] if r]
|
||||
|
||||
return EmailMessage(
|
||||
id=msg.get("Message-ID", ""),
|
||||
subject=subject,
|
||||
sender=sender,
|
||||
recipients=recipients,
|
||||
body=body,
|
||||
received_at=received_at,
|
||||
received_timestamp=received_timestamp,
|
||||
is_read=False, # 搜索的是未读邮件
|
||||
raw_data=raw[:500] if len(raw) > 500 else raw,
|
||||
)
|
||||
|
||||
@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(str(chunk))
|
||||
|
||||
return "".join(parts).strip()
|
||||
|
||||
@staticmethod
|
||||
def _extract_body(msg) -> str:
|
||||
"""提取邮件正文"""
|
||||
import html as html_module
|
||||
import re
|
||||
|
||||
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 test_connection(self) -> bool:
|
||||
"""
|
||||
测试 IMAP 连接
|
||||
|
||||
Returns:
|
||||
连接是否正常
|
||||
"""
|
||||
try:
|
||||
with self:
|
||||
self._conn.select("INBOX", readonly=True)
|
||||
self._conn.search(None, "ALL")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[{self.account.email}] IMAP 连接测试失败: {e}")
|
||||
return False
|
||||
487
src/services/outlook/service.py
Normal file
487
src/services/outlook/service.py
Normal file
@@ -0,0 +1,487 @@
|
||||
"""
|
||||
Outlook 邮箱服务主类
|
||||
支持多种 IMAP/API 连接方式,自动故障切换
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from ..base import BaseEmailService, EmailServiceError, EmailServiceStatus, EmailServiceType
|
||||
from ...config.constants import EmailServiceType as ServiceType
|
||||
from ...config.settings import get_settings
|
||||
from .account import OutlookAccount
|
||||
from .base import ProviderType, EmailMessage
|
||||
from .email_parser import EmailParser, get_email_parser
|
||||
from .health_checker import HealthChecker, FailoverManager
|
||||
from .providers.base import OutlookProvider, ProviderConfig
|
||||
from .providers.imap_old import IMAPOldProvider
|
||||
from .providers.imap_new import IMAPNewProvider
|
||||
from .providers.graph_api import GraphAPIProvider
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 默认提供者优先级
|
||||
# IMAP_OLD 最兼容(只需 login.live.com token),IMAP_NEW 次之,Graph API 最后
|
||||
# 原因:部分 client_id 没有 Graph API 权限,但有 IMAP 权限
|
||||
DEFAULT_PROVIDER_PRIORITY = [
|
||||
ProviderType.IMAP_OLD,
|
||||
ProviderType.IMAP_NEW,
|
||||
ProviderType.GRAPH_API,
|
||||
]
|
||||
|
||||
|
||||
def get_email_code_settings() -> dict:
|
||||
"""获取验证码等待配置"""
|
||||
settings = get_settings()
|
||||
return {
|
||||
"timeout": settings.email_code_timeout,
|
||||
"poll_interval": settings.email_code_poll_interval,
|
||||
}
|
||||
|
||||
|
||||
class OutlookService(BaseEmailService):
|
||||
"""
|
||||
Outlook 邮箱服务
|
||||
支持多种 IMAP/API 连接方式,自动故障切换
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None, name: str = None):
|
||||
"""
|
||||
初始化 Outlook 服务
|
||||
|
||||
Args:
|
||||
config: 配置字典,支持以下键:
|
||||
- accounts: Outlook 账户列表
|
||||
- provider_priority: 提供者优先级列表
|
||||
- health_failure_threshold: 连续失败次数阈值
|
||||
- health_disable_duration: 禁用时长(秒)
|
||||
- timeout: 请求超时时间
|
||||
- proxy_url: 代理 URL
|
||||
name: 服务名称
|
||||
"""
|
||||
super().__init__(ServiceType.OUTLOOK, name)
|
||||
|
||||
# 默认配置
|
||||
default_config = {
|
||||
"accounts": [],
|
||||
"provider_priority": [p.value for p in DEFAULT_PROVIDER_PRIORITY],
|
||||
"health_failure_threshold": 5,
|
||||
"health_disable_duration": 60,
|
||||
"timeout": 30,
|
||||
"proxy_url": None,
|
||||
}
|
||||
|
||||
self.config = {**default_config, **(config or {})}
|
||||
|
||||
# 解析提供者优先级
|
||||
self.provider_priority = [
|
||||
ProviderType(p) for p in self.config.get("provider_priority", [])
|
||||
]
|
||||
if not self.provider_priority:
|
||||
self.provider_priority = DEFAULT_PROVIDER_PRIORITY
|
||||
|
||||
# 提供者配置
|
||||
self.provider_config = ProviderConfig(
|
||||
timeout=self.config.get("timeout", 30),
|
||||
proxy_url=self.config.get("proxy_url"),
|
||||
health_failure_threshold=self.config.get("health_failure_threshold", 3),
|
||||
health_disable_duration=self.config.get("health_disable_duration", 300),
|
||||
)
|
||||
|
||||
# 获取默认 client_id(供无 client_id 的账户使用)
|
||||
try:
|
||||
_default_client_id = get_settings().outlook_default_client_id
|
||||
except Exception:
|
||||
_default_client_id = "24d9a0ed-8787-4584-883c-2fd79308940a"
|
||||
|
||||
# 解析账户
|
||||
self.accounts: List[OutlookAccount] = []
|
||||
self._current_account_index = 0
|
||||
self._account_lock = threading.Lock()
|
||||
|
||||
# 支持两种配置格式
|
||||
if "email" in self.config and "password" in self.config:
|
||||
account = OutlookAccount.from_config(self.config)
|
||||
if not account.client_id and _default_client_id:
|
||||
account.client_id = _default_client_id
|
||||
if account.validate():
|
||||
self.accounts.append(account)
|
||||
else:
|
||||
for account_config in self.config.get("accounts", []):
|
||||
account = OutlookAccount.from_config(account_config)
|
||||
if not account.client_id and _default_client_id:
|
||||
account.client_id = _default_client_id
|
||||
if account.validate():
|
||||
self.accounts.append(account)
|
||||
|
||||
if not self.accounts:
|
||||
logger.warning("未配置有效的 Outlook 账户")
|
||||
|
||||
# 健康检查器和故障切换管理器
|
||||
self.health_checker = HealthChecker(
|
||||
failure_threshold=self.provider_config.health_failure_threshold,
|
||||
disable_duration=self.provider_config.health_disable_duration,
|
||||
)
|
||||
self.failover_manager = FailoverManager(
|
||||
health_checker=self.health_checker,
|
||||
priority_order=self.provider_priority,
|
||||
)
|
||||
|
||||
# 邮件解析器
|
||||
self.email_parser = get_email_parser()
|
||||
|
||||
# 提供者实例缓存: (email, provider_type) -> OutlookProvider
|
||||
self._providers: Dict[tuple, OutlookProvider] = {}
|
||||
self._provider_lock = threading.Lock()
|
||||
|
||||
# IMAP 连接限制(防止限流)
|
||||
self._imap_semaphore = threading.Semaphore(5)
|
||||
|
||||
# 验证码去重机制
|
||||
self._used_codes: Dict[str, set] = {}
|
||||
|
||||
def _get_provider(
|
||||
self,
|
||||
account: OutlookAccount,
|
||||
provider_type: ProviderType,
|
||||
) -> OutlookProvider:
|
||||
"""
|
||||
获取或创建提供者实例
|
||||
|
||||
Args:
|
||||
account: Outlook 账户
|
||||
provider_type: 提供者类型
|
||||
|
||||
Returns:
|
||||
提供者实例
|
||||
"""
|
||||
cache_key = (account.email.lower(), provider_type)
|
||||
|
||||
with self._provider_lock:
|
||||
if cache_key not in self._providers:
|
||||
provider = self._create_provider(account, provider_type)
|
||||
self._providers[cache_key] = provider
|
||||
|
||||
return self._providers[cache_key]
|
||||
|
||||
def _create_provider(
|
||||
self,
|
||||
account: OutlookAccount,
|
||||
provider_type: ProviderType,
|
||||
) -> OutlookProvider:
|
||||
"""
|
||||
创建提供者实例
|
||||
|
||||
Args:
|
||||
account: Outlook 账户
|
||||
provider_type: 提供者类型
|
||||
|
||||
Returns:
|
||||
提供者实例
|
||||
"""
|
||||
if provider_type == ProviderType.IMAP_OLD:
|
||||
return IMAPOldProvider(account, self.provider_config)
|
||||
elif provider_type == ProviderType.IMAP_NEW:
|
||||
return IMAPNewProvider(account, self.provider_config)
|
||||
elif provider_type == ProviderType.GRAPH_API:
|
||||
return GraphAPIProvider(account, self.provider_config)
|
||||
else:
|
||||
raise ValueError(f"未知的提供者类型: {provider_type}")
|
||||
|
||||
def _get_provider_priority_for_account(self, account: OutlookAccount) -> List[ProviderType]:
|
||||
"""根据账户是否有 OAuth,返回适合的提供者优先级列表"""
|
||||
if account.has_oauth():
|
||||
return self.provider_priority
|
||||
else:
|
||||
# 无 OAuth,直接走旧版 IMAP(密码认证),跳过需要 OAuth 的提供者
|
||||
return [ProviderType.IMAP_OLD]
|
||||
|
||||
def _try_providers_for_emails(
|
||||
self,
|
||||
account: OutlookAccount,
|
||||
count: int = 20,
|
||||
only_unseen: bool = True,
|
||||
) -> List[EmailMessage]:
|
||||
"""
|
||||
尝试多个提供者获取邮件
|
||||
|
||||
Args:
|
||||
account: Outlook 账户
|
||||
count: 获取数量
|
||||
only_unseen: 是否只获取未读
|
||||
|
||||
Returns:
|
||||
邮件列表
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# 根据账户类型选择合适的提供者优先级
|
||||
priority = self._get_provider_priority_for_account(account)
|
||||
|
||||
# 按优先级尝试各提供者
|
||||
for provider_type in priority:
|
||||
# 检查提供者是否可用
|
||||
if not self.health_checker.is_available(provider_type):
|
||||
logger.debug(
|
||||
f"[{account.email}] {provider_type.value} 不可用,跳过"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
provider = self._get_provider(account, provider_type)
|
||||
|
||||
with self._imap_semaphore:
|
||||
with provider:
|
||||
emails = provider.get_recent_emails(count, only_unseen)
|
||||
|
||||
if emails:
|
||||
# 成功获取邮件
|
||||
self.health_checker.record_success(provider_type)
|
||||
logger.debug(
|
||||
f"[{account.email}] {provider_type.value} 获取到 {len(emails)} 封邮件"
|
||||
)
|
||||
return emails
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
errors.append(f"{provider_type.value}: {error_msg}")
|
||||
self.health_checker.record_failure(provider_type, error_msg)
|
||||
logger.warning(
|
||||
f"[{account.email}] {provider_type.value} 获取邮件失败: {e}"
|
||||
)
|
||||
|
||||
logger.error(
|
||||
f"[{account.email}] 所有提供者都失败: {'; '.join(errors)}"
|
||||
)
|
||||
return []
|
||||
|
||||
def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
选择可用的 Outlook 账户
|
||||
|
||||
Args:
|
||||
config: 配置参数(未使用)
|
||||
|
||||
Returns:
|
||||
包含邮箱信息的字典
|
||||
"""
|
||||
if not self.accounts:
|
||||
self.update_status(False, EmailServiceError("没有可用的 Outlook 账户"))
|
||||
raise EmailServiceError("没有可用的 Outlook 账户")
|
||||
|
||||
# 轮询选择账户
|
||||
with self._account_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,
|
||||
"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 = None,
|
||||
otp_sent_at: Optional[float] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
从 Outlook 邮箱获取验证码
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
email_id: 未使用
|
||||
timeout: 超时时间(秒)
|
||||
pattern: 验证码正则表达式(未使用)
|
||||
otp_sent_at: OTP 发送时间戳
|
||||
|
||||
Returns:
|
||||
验证码字符串
|
||||
"""
|
||||
# 查找对应的账户
|
||||
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,"
|
||||
f"提供者优先级: {[p.value for p in self.provider_priority]}"
|
||||
)
|
||||
|
||||
# 初始化验证码去重集合
|
||||
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
|
||||
|
||||
# 渐进式邮件检查:前 3 次只检查未读
|
||||
only_unseen = poll_count <= 3
|
||||
|
||||
try:
|
||||
# 尝试多个提供者获取邮件
|
||||
emails = self._try_providers_for_emails(
|
||||
account,
|
||||
count=15,
|
||||
only_unseen=only_unseen,
|
||||
)
|
||||
|
||||
if emails:
|
||||
logger.debug(
|
||||
f"[{email}] 第 {poll_count} 次轮询获取到 {len(emails)} 封邮件"
|
||||
)
|
||||
|
||||
# 从邮件中查找验证码
|
||||
code = self.email_parser.find_verification_code_in_emails(
|
||||
emails,
|
||||
target_email=email,
|
||||
min_timestamp=min_timestamp,
|
||||
used_codes=used_codes,
|
||||
)
|
||||
|
||||
if code:
|
||||
used_codes.add(code)
|
||||
elapsed = int(time.time() - start_time)
|
||||
logger.info(
|
||||
f"[{email}] 找到验证码: {code},"
|
||||
f"总耗时 {elapsed}s,轮询 {poll_count} 次"
|
||||
)
|
||||
self.update_status(True)
|
||||
return code
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[{email}] 检查出错: {e}")
|
||||
|
||||
# 等待下次轮询
|
||||
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 账户"""
|
||||
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 不支持删除账户)"""
|
||||
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]
|
||||
|
||||
# 尝试任一提供者连接
|
||||
for provider_type in self.provider_priority:
|
||||
try:
|
||||
provider = self._get_provider(test_account, provider_type)
|
||||
if provider.test_connection():
|
||||
self.update_status(True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Outlook 健康检查失败 ({test_account.email}, {provider_type.value}): {e}"
|
||||
)
|
||||
|
||||
self.update_status(False, EmailServiceError("健康检查失败"))
|
||||
return False
|
||||
|
||||
def get_provider_status(self) -> Dict[str, Any]:
|
||||
"""获取提供者状态"""
|
||||
return self.failover_manager.get_status()
|
||||
|
||||
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": [acc.to_dict() for acc in self.accounts],
|
||||
"provider_status": self.get_provider_status(),
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
logger.info(f"移除 Outlook 账户: {email}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def reset_provider_health(self):
|
||||
"""重置所有提供者的健康状态"""
|
||||
self.health_checker.reset_all()
|
||||
logger.info("已重置所有提供者的健康状态")
|
||||
|
||||
def force_provider(self, provider_type: ProviderType):
|
||||
"""强制使用指定的提供者"""
|
||||
self.health_checker.force_enable(provider_type)
|
||||
# 禁用其他提供者
|
||||
for pt in ProviderType:
|
||||
if pt != provider_type:
|
||||
self.health_checker.force_disable(pt, 60)
|
||||
logger.info(f"已强制使用提供者: {provider_type.value}")
|
||||
239
src/services/outlook/token_manager.py
Normal file
239
src/services/outlook/token_manager.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Token 管理器
|
||||
支持多个 Microsoft Token 端点,自动选择合适的端点
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
from curl_cffi import requests as _requests
|
||||
|
||||
from .base import ProviderType, TokenEndpoint, TokenInfo
|
||||
from .account import OutlookAccount
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 各提供者的 Scope 配置
|
||||
PROVIDER_SCOPES = {
|
||||
ProviderType.IMAP_OLD: "", # 旧版 IMAP 不需要特定 scope
|
||||
ProviderType.IMAP_NEW: "https://outlook.office.com/IMAP.AccessAsUser.All offline_access",
|
||||
ProviderType.GRAPH_API: "https://graph.microsoft.com/.default",
|
||||
}
|
||||
|
||||
# 各提供者的 Token 端点
|
||||
PROVIDER_TOKEN_URLS = {
|
||||
ProviderType.IMAP_OLD: TokenEndpoint.LIVE.value,
|
||||
ProviderType.IMAP_NEW: TokenEndpoint.CONSUMERS.value,
|
||||
ProviderType.GRAPH_API: TokenEndpoint.COMMON.value,
|
||||
}
|
||||
|
||||
|
||||
class TokenManager:
|
||||
"""
|
||||
Token 管理器
|
||||
支持多端点 Token 获取和缓存
|
||||
"""
|
||||
|
||||
# Token 缓存: key = (email, provider_type) -> TokenInfo
|
||||
_token_cache: Dict[tuple, TokenInfo] = {}
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
# 默认超时时间
|
||||
DEFAULT_TIMEOUT = 30
|
||||
# Token 刷新提前时间(秒)
|
||||
REFRESH_BUFFER = 120
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
account: OutlookAccount,
|
||||
provider_type: ProviderType,
|
||||
proxy_url: Optional[str] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
):
|
||||
"""
|
||||
初始化 Token 管理器
|
||||
|
||||
Args:
|
||||
account: Outlook 账户
|
||||
provider_type: 提供者类型
|
||||
proxy_url: 代理 URL(可选)
|
||||
timeout: 请求超时时间
|
||||
"""
|
||||
self.account = account
|
||||
self.provider_type = provider_type
|
||||
self.proxy_url = proxy_url
|
||||
self.timeout = timeout
|
||||
|
||||
# 获取端点和 Scope
|
||||
self.token_url = PROVIDER_TOKEN_URLS.get(provider_type, TokenEndpoint.LIVE.value)
|
||||
self.scope = PROVIDER_SCOPES.get(provider_type, "")
|
||||
|
||||
def get_cached_token(self) -> Optional[TokenInfo]:
|
||||
"""获取缓存的 Token"""
|
||||
cache_key = (self.account.email.lower(), self.provider_type)
|
||||
with self._cache_lock:
|
||||
token = self._token_cache.get(cache_key)
|
||||
if token and not token.is_expired(self.REFRESH_BUFFER):
|
||||
return token
|
||||
return None
|
||||
|
||||
def set_cached_token(self, token: TokenInfo):
|
||||
"""缓存 Token"""
|
||||
cache_key = (self.account.email.lower(), self.provider_type)
|
||||
with self._cache_lock:
|
||||
self._token_cache[cache_key] = token
|
||||
|
||||
def clear_cache(self):
|
||||
"""清除缓存"""
|
||||
cache_key = (self.account.email.lower(), self.provider_type)
|
||||
with self._cache_lock:
|
||||
self._token_cache.pop(cache_key, None)
|
||||
|
||||
def get_access_token(self, force_refresh: bool = False) -> Optional[str]:
|
||||
"""
|
||||
获取 Access Token
|
||||
|
||||
Args:
|
||||
force_refresh: 是否强制刷新
|
||||
|
||||
Returns:
|
||||
Access Token 字符串,失败返回 None
|
||||
"""
|
||||
# 检查缓存
|
||||
if not force_refresh:
|
||||
cached = self.get_cached_token()
|
||||
if cached:
|
||||
logger.debug(f"[{self.account.email}] 使用缓存的 Token ({self.provider_type.value})")
|
||||
return cached.access_token
|
||||
|
||||
# 刷新 Token
|
||||
try:
|
||||
token = self._refresh_token()
|
||||
if token:
|
||||
self.set_cached_token(token)
|
||||
return token.access_token
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.account.email}] 获取 Token 失败 ({self.provider_type.value}): {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _refresh_token(self) -> Optional[TokenInfo]:
|
||||
"""
|
||||
刷新 Token
|
||||
|
||||
Returns:
|
||||
TokenInfo 对象,失败返回 None
|
||||
"""
|
||||
if not self.account.client_id or not self.account.refresh_token:
|
||||
raise ValueError("缺少 client_id 或 refresh_token")
|
||||
|
||||
logger.debug(f"[{self.account.email}] 正在刷新 Token ({self.provider_type.value})...")
|
||||
logger.debug(f"[{self.account.email}] Token URL: {self.token_url}")
|
||||
|
||||
# 构建请求体
|
||||
data = {
|
||||
"client_id": self.account.client_id,
|
||||
"refresh_token": self.account.refresh_token,
|
||||
"grant_type": "refresh_token",
|
||||
}
|
||||
|
||||
# 添加 Scope(如果需要)
|
||||
if self.scope:
|
||||
data["scope"] = self.scope
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
proxies = None
|
||||
if self.proxy_url:
|
||||
proxies = {"http": self.proxy_url, "https": self.proxy_url}
|
||||
|
||||
try:
|
||||
resp = _requests.post(
|
||||
self.token_url,
|
||||
data=data,
|
||||
headers=headers,
|
||||
proxies=proxies,
|
||||
timeout=self.timeout,
|
||||
impersonate="chrome110",
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
error_body = resp.text
|
||||
logger.error(f"[{self.account.email}] Token 刷新失败: HTTP {resp.status_code}")
|
||||
logger.debug(f"[{self.account.email}] 错误响应: {error_body[:500]}")
|
||||
|
||||
if "service abuse" in error_body.lower():
|
||||
logger.warning(f"[{self.account.email}] 账号可能被封禁")
|
||||
elif "invalid_grant" in error_body.lower():
|
||||
logger.warning(f"[{self.account.email}] Refresh Token 已失效")
|
||||
|
||||
return None
|
||||
|
||||
response_data = resp.json()
|
||||
|
||||
# 解析响应
|
||||
token = TokenInfo.from_response(response_data, self.scope)
|
||||
logger.info(
|
||||
f"[{self.account.email}] Token 刷新成功 ({self.provider_type.value}), "
|
||||
f"有效期 {int(token.expires_at - time.time())} 秒"
|
||||
)
|
||||
return token
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"[{self.account.email}] JSON 解析错误: {e}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.account.email}] 未知错误: {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def clear_all_cache(cls):
|
||||
"""清除所有 Token 缓存"""
|
||||
with cls._cache_lock:
|
||||
cls._token_cache.clear()
|
||||
logger.info("已清除所有 Token 缓存")
|
||||
|
||||
@classmethod
|
||||
def get_cache_stats(cls) -> Dict[str, Any]:
|
||||
"""获取缓存统计"""
|
||||
with cls._cache_lock:
|
||||
return {
|
||||
"cache_size": len(cls._token_cache),
|
||||
"entries": [
|
||||
{
|
||||
"email": key[0],
|
||||
"provider": key[1].value,
|
||||
}
|
||||
for key in cls._token_cache.keys()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def create_token_manager(
|
||||
account: OutlookAccount,
|
||||
provider_type: ProviderType,
|
||||
proxy_url: Optional[str] = None,
|
||||
timeout: int = TokenManager.DEFAULT_TIMEOUT,
|
||||
) -> TokenManager:
|
||||
"""
|
||||
创建 Token 管理器的工厂函数
|
||||
|
||||
Args:
|
||||
account: Outlook 账户
|
||||
provider_type: 提供者类型
|
||||
proxy_url: 代理 URL
|
||||
timeout: 超时时间
|
||||
|
||||
Returns:
|
||||
TokenManager 实例
|
||||
"""
|
||||
return TokenManager(account, provider_type, proxy_url, timeout)
|
||||
763
src/services/outlook_legacy_mail.py
Normal file
763
src/services/outlook_legacy_mail.py
Normal file
@@ -0,0 +1,763 @@
|
||||
"""
|
||||
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
|
||||
from ..config.constants import (
|
||||
OTP_CODE_PATTERN,
|
||||
OTP_CODE_SIMPLE_PATTERN,
|
||||
OTP_CODE_SEMANTIC_PATTERN,
|
||||
OPENAI_EMAIL_SENDERS,
|
||||
OPENAI_VERIFICATION_KEYWORDS,
|
||||
)
|
||||
from ..config.settings import get_settings
|
||||
|
||||
|
||||
def get_email_code_settings() -> dict:
|
||||
"""
|
||||
获取验证码等待配置
|
||||
|
||||
Returns:
|
||||
dict: 包含 timeout 和 poll_interval 的字典
|
||||
"""
|
||||
settings = get_settings()
|
||||
return {
|
||||
"timeout": settings.email_code_timeout,
|
||||
"poll_interval": settings.email_code_poll_interval,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
455
src/services/temp_mail.py
Normal file
455
src/services/temp_mail.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
Temp-Mail 邮箱服务实现
|
||||
基于自部署 Cloudflare Worker 临时邮箱服务
|
||||
接口文档参见 plan/temp-mail.md
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from email import message_from_string
|
||||
from email.header import decode_header, make_header
|
||||
from email.message import Message
|
||||
from email.policy import default as email_policy
|
||||
from html import unescape
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from .base import BaseEmailService, EmailServiceError, EmailServiceType
|
||||
from ..core.http_client import HTTPClient, RequestConfig
|
||||
from ..config.constants import OTP_CODE_PATTERN
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TempMailService(BaseEmailService):
|
||||
"""
|
||||
Temp-Mail 邮箱服务
|
||||
基于自部署 Cloudflare Worker 的临时邮箱,admin 模式管理邮箱
|
||||
不走代理,不使用 requests 库
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None, name: str = None):
|
||||
"""
|
||||
初始化 TempMail 服务
|
||||
|
||||
Args:
|
||||
config: 配置字典,支持以下键:
|
||||
- base_url: Worker 域名地址,如 https://mail.example.com (必需)
|
||||
- admin_password: Admin 密码,对应 x-admin-auth header (必需)
|
||||
- domain: 邮箱域名,如 example.com (必需)
|
||||
- enable_prefix: 是否启用前缀,默认 True
|
||||
- timeout: 请求超时时间,默认 30
|
||||
- max_retries: 最大重试次数,默认 3
|
||||
name: 服务名称
|
||||
"""
|
||||
super().__init__(EmailServiceType.TEMP_MAIL, name)
|
||||
|
||||
required_keys = ["base_url", "admin_password", "domain"]
|
||||
missing_keys = [key for key in required_keys if not (config or {}).get(key)]
|
||||
if missing_keys:
|
||||
raise ValueError(f"缺少必需配置: {missing_keys}")
|
||||
|
||||
default_config = {
|
||||
"enable_prefix": True,
|
||||
"timeout": 30,
|
||||
"max_retries": 3,
|
||||
}
|
||||
self.config = {**default_config, **(config or {})}
|
||||
|
||||
# 不走代理,proxy_url=None
|
||||
http_config = RequestConfig(
|
||||
timeout=self.config["timeout"],
|
||||
max_retries=self.config["max_retries"],
|
||||
)
|
||||
self.http_client = HTTPClient(proxy_url=None, config=http_config)
|
||||
|
||||
# 邮箱缓存:email -> {jwt, address}
|
||||
self._email_cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def _decode_mime_header(self, value: str) -> str:
|
||||
"""解码 MIME 头,兼容 RFC 2047 编码主题。"""
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return str(make_header(decode_header(value)))
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
def _extract_body_from_message(self, message: Message) -> str:
|
||||
"""从 MIME 邮件对象中提取可读正文。"""
|
||||
parts: List[str] = []
|
||||
|
||||
if message.is_multipart():
|
||||
for part in message.walk():
|
||||
if part.get_content_maintype() == "multipart":
|
||||
continue
|
||||
|
||||
content_type = (part.get_content_type() or "").lower()
|
||||
if content_type not in ("text/plain", "text/html"):
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = part.get_payload(decode=True)
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
text = payload.decode(charset, errors="replace") if payload else ""
|
||||
except Exception:
|
||||
try:
|
||||
text = part.get_content()
|
||||
except Exception:
|
||||
text = ""
|
||||
|
||||
if content_type == "text/html":
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
parts.append(text)
|
||||
else:
|
||||
try:
|
||||
payload = message.get_payload(decode=True)
|
||||
charset = message.get_content_charset() or "utf-8"
|
||||
body = payload.decode(charset, errors="replace") if payload else ""
|
||||
except Exception:
|
||||
try:
|
||||
body = message.get_content()
|
||||
except Exception:
|
||||
body = str(message.get_payload() or "")
|
||||
|
||||
if "html" in (message.get_content_type() or "").lower():
|
||||
body = re.sub(r"<[^>]+>", " ", body)
|
||||
parts.append(body)
|
||||
|
||||
return unescape("\n".join(part for part in parts if part).strip())
|
||||
|
||||
def _extract_mail_fields(self, mail: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""统一提取邮件字段,兼容 raw MIME 和不同 Worker 返回格式。"""
|
||||
sender = str(
|
||||
mail.get("source")
|
||||
or mail.get("from")
|
||||
or mail.get("from_address")
|
||||
or mail.get("fromAddress")
|
||||
or ""
|
||||
).strip()
|
||||
subject = str(mail.get("subject") or mail.get("title") or "").strip()
|
||||
body_text = str(
|
||||
mail.get("text")
|
||||
or mail.get("body")
|
||||
or mail.get("content")
|
||||
or mail.get("html")
|
||||
or ""
|
||||
).strip()
|
||||
raw = str(mail.get("raw") or "").strip()
|
||||
|
||||
if raw:
|
||||
try:
|
||||
message = message_from_string(raw, policy=email_policy)
|
||||
sender = sender or self._decode_mime_header(message.get("From", ""))
|
||||
subject = subject or self._decode_mime_header(message.get("Subject", ""))
|
||||
parsed_body = self._extract_body_from_message(message)
|
||||
if parsed_body:
|
||||
body_text = f"{body_text}\n{parsed_body}".strip() if body_text else parsed_body
|
||||
except Exception as e:
|
||||
logger.debug(f"解析 TempMail raw 邮件失败: {e}")
|
||||
body_text = f"{body_text}\n{raw}".strip() if body_text else raw
|
||||
|
||||
body_text = unescape(re.sub(r"<[^>]+>", " ", body_text))
|
||||
return {
|
||||
"sender": sender,
|
||||
"subject": subject,
|
||||
"body": body_text,
|
||||
"raw": raw,
|
||||
}
|
||||
|
||||
def _admin_headers(self) -> Dict[str, str]:
|
||||
"""构造 admin 请求头"""
|
||||
return {
|
||||
"x-admin-auth": self.config["admin_password"],
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
def _make_request(self, method: str, path: str, **kwargs) -> Any:
|
||||
"""
|
||||
发送请求并返回 JSON 数据
|
||||
|
||||
Args:
|
||||
method: HTTP 方法
|
||||
path: 请求路径(以 / 开头)
|
||||
**kwargs: 传递给 http_client.request 的额外参数
|
||||
|
||||
Returns:
|
||||
响应 JSON 数据
|
||||
|
||||
Raises:
|
||||
EmailServiceError: 请求失败
|
||||
"""
|
||||
base_url = self.config["base_url"].rstrip("/")
|
||||
url = f"{base_url}{path}"
|
||||
|
||||
# 合并默认 admin headers
|
||||
kwargs.setdefault("headers", {})
|
||||
for k, v in self._admin_headers().items():
|
||||
kwargs["headers"].setdefault(k, v)
|
||||
|
||||
try:
|
||||
response = self.http_client.request(method, url, **kwargs)
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_msg = f"请求失败: {response.status_code}"
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_msg = f"{error_msg} - {error_data}"
|
||||
except Exception:
|
||||
error_msg = f"{error_msg} - {response.text[:200]}"
|
||||
self.update_status(False, EmailServiceError(error_msg))
|
||||
raise EmailServiceError(error_msg)
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except json.JSONDecodeError:
|
||||
return {"raw_response": response.text}
|
||||
|
||||
except Exception as e:
|
||||
self.update_status(False, e)
|
||||
if isinstance(e, EmailServiceError):
|
||||
raise
|
||||
raise EmailServiceError(f"请求失败: {method} {path} - {e}")
|
||||
|
||||
def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
通过 admin API 创建临时邮箱
|
||||
|
||||
Returns:
|
||||
包含邮箱信息的字典:
|
||||
- email: 邮箱地址
|
||||
- jwt: 用户级 JWT token
|
||||
- service_id: 同 email(用作标识)
|
||||
"""
|
||||
import random
|
||||
import string
|
||||
|
||||
# 生成随机邮箱名
|
||||
letters = ''.join(random.choices(string.ascii_lowercase, k=5))
|
||||
digits = ''.join(random.choices(string.digits, k=random.randint(1, 3)))
|
||||
suffix = ''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3)))
|
||||
name = letters + digits + suffix
|
||||
|
||||
domain = self.config["domain"]
|
||||
enable_prefix = self.config.get("enable_prefix", True)
|
||||
|
||||
body = {
|
||||
"enablePrefix": enable_prefix,
|
||||
"name": name,
|
||||
"domain": domain,
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._make_request("POST", "/admin/new_address", json=body)
|
||||
|
||||
address = response.get("address", "").strip()
|
||||
jwt = response.get("jwt", "").strip()
|
||||
|
||||
if not address:
|
||||
raise EmailServiceError(f"API 返回数据不完整: {response}")
|
||||
|
||||
email_info = {
|
||||
"email": address,
|
||||
"jwt": jwt,
|
||||
"service_id": address,
|
||||
"id": address,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
|
||||
# 缓存 jwt,供获取验证码时使用
|
||||
self._email_cache[address] = email_info
|
||||
|
||||
logger.info(f"成功创建 TempMail 邮箱: {address}")
|
||||
self.update_status(True)
|
||||
return email_info
|
||||
|
||||
except Exception as e:
|
||||
self.update_status(False, e)
|
||||
if isinstance(e, EmailServiceError):
|
||||
raise
|
||||
raise EmailServiceError(f"创建邮箱失败: {e}")
|
||||
|
||||
def get_verification_code(
|
||||
self,
|
||||
email: str,
|
||||
email_id: str = None,
|
||||
timeout: int = 120,
|
||||
pattern: str = OTP_CODE_PATTERN,
|
||||
otp_sent_at: Optional[float] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
从 TempMail 邮箱获取验证码
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
email_id: 未使用,保留接口兼容
|
||||
timeout: 超时时间(秒)
|
||||
pattern: 验证码正则
|
||||
otp_sent_at: OTP 发送时间戳(暂未使用)
|
||||
|
||||
Returns:
|
||||
验证码字符串,超时返回 None
|
||||
"""
|
||||
logger.info(f"正在从 TempMail 邮箱 {email} 获取验证码...")
|
||||
|
||||
start_time = time.time()
|
||||
seen_mail_ids: set = set()
|
||||
|
||||
# 优先使用用户级 JWT,回退到 admin API 先注释用户级API
|
||||
# cached = self._email_cache.get(email, {})
|
||||
# jwt = cached.get("jwt")
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
# if jwt:
|
||||
# response = self._make_request(
|
||||
# "GET",
|
||||
# "/user_api/mails",
|
||||
# params={"limit": 20, "offset": 0},
|
||||
# headers={"x-user-token": jwt, "Content-Type": "application/json", "Accept": "application/json"},
|
||||
# )
|
||||
# else:
|
||||
response = self._make_request(
|
||||
"GET",
|
||||
"/admin/mails",
|
||||
params={"limit": 20, "offset": 0, "address": email},
|
||||
)
|
||||
|
||||
# /user_api/mails 和 /admin/mails 返回格式相同: {"results": [...], "total": N}
|
||||
mails = response.get("results", [])
|
||||
if not isinstance(mails, list):
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
for mail in mails:
|
||||
mail_id = mail.get("id")
|
||||
if not mail_id or mail_id in seen_mail_ids:
|
||||
continue
|
||||
|
||||
seen_mail_ids.add(mail_id)
|
||||
|
||||
parsed = self._extract_mail_fields(mail)
|
||||
sender = parsed["sender"].lower()
|
||||
subject = parsed["subject"]
|
||||
body_text = parsed["body"]
|
||||
raw_text = parsed["raw"]
|
||||
content = f"{sender}\n{subject}\n{body_text}\n{raw_text}".strip()
|
||||
|
||||
# 只处理 OpenAI 邮件
|
||||
if "openai" not in sender and "openai" not in content.lower():
|
||||
continue
|
||||
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
code = match.group(1)
|
||||
logger.info(f"从 TempMail 邮箱 {email} 找到验证码: {code}")
|
||||
self.update_status(True)
|
||||
return code
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"检查 TempMail 邮件时出错: {e}")
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
logger.warning(f"等待 TempMail 验证码超时: {email}")
|
||||
return None
|
||||
|
||||
def list_emails(self, limit: int = 100, offset: int = 0, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
列出邮箱
|
||||
|
||||
Args:
|
||||
limit: 返回数量上限
|
||||
offset: 分页偏移
|
||||
**kwargs: 额外查询参数,透传给 admin API
|
||||
|
||||
Returns:
|
||||
邮箱列表
|
||||
"""
|
||||
params = {
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
params.update({k: v for k, v in kwargs.items() if v is not None})
|
||||
|
||||
try:
|
||||
response = self._make_request("GET", "/admin/mails", params=params)
|
||||
mails = response.get("results", [])
|
||||
if not isinstance(mails, list):
|
||||
raise EmailServiceError(f"API 返回数据格式错误: {response}")
|
||||
|
||||
emails: List[Dict[str, Any]] = []
|
||||
for mail in mails:
|
||||
address = (mail.get("address") or "").strip()
|
||||
mail_id = mail.get("id") or address
|
||||
email_info = {
|
||||
"id": mail_id,
|
||||
"service_id": mail_id,
|
||||
"email": address,
|
||||
"subject": mail.get("subject"),
|
||||
"from": mail.get("source"),
|
||||
"created_at": mail.get("createdAt") or mail.get("created_at"),
|
||||
"raw_data": mail,
|
||||
}
|
||||
emails.append(email_info)
|
||||
|
||||
if address:
|
||||
cached = self._email_cache.get(address, {})
|
||||
self._email_cache[address] = {**cached, **email_info}
|
||||
|
||||
self.update_status(True)
|
||||
return emails
|
||||
except Exception as e:
|
||||
logger.warning(f"列出 TempMail 邮箱失败: {e}")
|
||||
self.update_status(False, e)
|
||||
return list(self._email_cache.values())
|
||||
|
||||
def delete_email(self, email_id: str) -> bool:
|
||||
"""
|
||||
删除邮箱
|
||||
|
||||
Note:
|
||||
当前 TempMail admin API 文档未见删除地址接口,这里先从本地缓存移除,
|
||||
以满足统一接口并避免服务实例化失败。
|
||||
"""
|
||||
removed = False
|
||||
emails_to_delete = []
|
||||
|
||||
for address, info in self._email_cache.items():
|
||||
candidate_ids = {
|
||||
address,
|
||||
info.get("id"),
|
||||
info.get("service_id"),
|
||||
}
|
||||
if email_id in candidate_ids:
|
||||
emails_to_delete.append(address)
|
||||
|
||||
for address in emails_to_delete:
|
||||
self._email_cache.pop(address, None)
|
||||
removed = True
|
||||
|
||||
if removed:
|
||||
logger.info(f"已从 TempMail 缓存移除邮箱: {email_id}")
|
||||
self.update_status(True)
|
||||
else:
|
||||
logger.info(f"TempMail 缓存中未找到邮箱: {email_id}")
|
||||
|
||||
return removed
|
||||
|
||||
def check_health(self) -> bool:
|
||||
"""检查服务健康状态"""
|
||||
try:
|
||||
self._make_request(
|
||||
"GET",
|
||||
"/admin/mails",
|
||||
params={"limit": 1, "offset": 0},
|
||||
)
|
||||
self.update_status(True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"TempMail 健康检查失败: {e}")
|
||||
self.update_status(False, e)
|
||||
return False
|
||||
400
src/services/tempmail.py
Normal file
400
src/services/tempmail.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
Tempmail.lol 邮箱服务实现
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
import json
|
||||
|
||||
from curl_cffi import requests as cffi_requests
|
||||
|
||||
from .base import BaseEmailService, EmailServiceError, EmailServiceType
|
||||
from ..core.http_client import HTTPClient, RequestConfig
|
||||
from ..config.constants import OTP_CODE_PATTERN
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TempmailService(BaseEmailService):
|
||||
"""
|
||||
Tempmail.lol 邮箱服务
|
||||
基于 Tempmail.lol API v2
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None, name: str = None):
|
||||
"""
|
||||
初始化 Tempmail 服务
|
||||
|
||||
Args:
|
||||
config: 配置字典,支持以下键:
|
||||
- base_url: API 基础地址 (默认: https://api.tempmail.lol/v2)
|
||||
- timeout: 请求超时时间 (默认: 30)
|
||||
- max_retries: 最大重试次数 (默认: 3)
|
||||
- proxy_url: 代理 URL
|
||||
name: 服务名称
|
||||
"""
|
||||
super().__init__(EmailServiceType.TEMPMAIL, name)
|
||||
|
||||
# 默认配置
|
||||
default_config = {
|
||||
"base_url": "https://api.tempmail.lol/v2",
|
||||
"timeout": 30,
|
||||
"max_retries": 3,
|
||||
"proxy_url": None,
|
||||
}
|
||||
|
||||
self.config = {**default_config, **(config or {})}
|
||||
|
||||
# 创建 HTTP 客户端
|
||||
http_config = RequestConfig(
|
||||
timeout=self.config["timeout"],
|
||||
max_retries=self.config["max_retries"],
|
||||
)
|
||||
self.http_client = HTTPClient(
|
||||
proxy_url=self.config.get("proxy_url"),
|
||||
config=http_config
|
||||
)
|
||||
|
||||
# 状态变量
|
||||
self._email_cache: Dict[str, Dict[str, Any]] = {}
|
||||
self._last_check_time: float = 0
|
||||
|
||||
def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
创建新的临时邮箱
|
||||
|
||||
Args:
|
||||
config: 配置参数(Tempmail.lol 目前不支持自定义配置)
|
||||
|
||||
Returns:
|
||||
包含邮箱信息的字典:
|
||||
- email: 邮箱地址
|
||||
- service_id: 邮箱 token
|
||||
- token: 邮箱 token(同 service_id)
|
||||
- created_at: 创建时间戳
|
||||
"""
|
||||
try:
|
||||
# 发送创建请求
|
||||
response = self.http_client.post(
|
||||
f"{self.config['base_url']}/inbox/create",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={}
|
||||
)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
self.update_status(False, EmailServiceError(f"请求失败,状态码: {response.status_code}"))
|
||||
raise EmailServiceError(f"Tempmail.lol 请求失败,状态码: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
email = str(data.get("address", "")).strip()
|
||||
token = str(data.get("token", "")).strip()
|
||||
|
||||
if not email or not token:
|
||||
self.update_status(False, EmailServiceError("返回数据不完整"))
|
||||
raise EmailServiceError("Tempmail.lol 返回数据不完整")
|
||||
|
||||
# 缓存邮箱信息
|
||||
email_info = {
|
||||
"email": email,
|
||||
"service_id": token,
|
||||
"token": token,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
self._email_cache[email] = email_info
|
||||
|
||||
logger.info(f"成功创建 Tempmail.lol 邮箱: {email}")
|
||||
self.update_status(True)
|
||||
return email_info
|
||||
|
||||
except Exception as e:
|
||||
self.update_status(False, e)
|
||||
if isinstance(e, EmailServiceError):
|
||||
raise
|
||||
raise EmailServiceError(f"创建 Tempmail.lol 邮箱失败: {e}")
|
||||
|
||||
def get_verification_code(
|
||||
self,
|
||||
email: str,
|
||||
email_id: str = None,
|
||||
timeout: int = 120,
|
||||
pattern: str = OTP_CODE_PATTERN,
|
||||
otp_sent_at: Optional[float] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
从 Tempmail.lol 获取验证码
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
email_id: 邮箱 token(如果不提供,从缓存中查找)
|
||||
timeout: 超时时间(秒)
|
||||
pattern: 验证码正则表达式
|
||||
otp_sent_at: OTP 发送时间戳(Tempmail 服务暂不使用此参数)
|
||||
|
||||
Returns:
|
||||
验证码字符串,如果超时或未找到返回 None
|
||||
"""
|
||||
token = email_id
|
||||
if not token:
|
||||
# 从缓存中查找 token
|
||||
if email in self._email_cache:
|
||||
token = self._email_cache[email].get("token")
|
||||
else:
|
||||
logger.warning(f"未找到邮箱 {email} 的 token,无法获取验证码")
|
||||
return None
|
||||
|
||||
if not token:
|
||||
logger.warning(f"邮箱 {email} 没有 token,无法获取验证码")
|
||||
return None
|
||||
|
||||
logger.info(f"正在等待邮箱 {email} 的验证码...")
|
||||
|
||||
start_time = time.time()
|
||||
seen_ids = set()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
# 获取邮件列表
|
||||
response = self.http_client.get(
|
||||
f"{self.config['base_url']}/inbox",
|
||||
params={"token": token},
|
||||
headers={"Accept": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
data = response.json()
|
||||
|
||||
# 检查 inbox 是否过期
|
||||
if data is None or (isinstance(data, dict) and not data):
|
||||
logger.warning(f"邮箱 {email} 已过期")
|
||||
return None
|
||||
|
||||
email_list = data.get("emails", []) if isinstance(data, dict) else []
|
||||
|
||||
if not isinstance(email_list, list):
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
for msg in email_list:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
|
||||
# 使用 date 作为唯一标识
|
||||
msg_date = msg.get("date", 0)
|
||||
if not msg_date or msg_date in seen_ids:
|
||||
continue
|
||||
seen_ids.add(msg_date)
|
||||
|
||||
sender = str(msg.get("from", "")).lower()
|
||||
subject = str(msg.get("subject", ""))
|
||||
body = str(msg.get("body", ""))
|
||||
html = str(msg.get("html") or "")
|
||||
|
||||
content = "\n".join([sender, subject, body, html])
|
||||
|
||||
# 检查是否是 OpenAI 邮件
|
||||
if "openai" not in sender and "openai" not in content.lower():
|
||||
continue
|
||||
|
||||
# 提取验证码
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
code = match.group(1)
|
||||
logger.info(f"找到验证码: {code}")
|
||||
self.update_status(True)
|
||||
return code
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"检查邮件时出错: {e}")
|
||||
|
||||
# 等待一段时间再检查
|
||||
time.sleep(3)
|
||||
|
||||
logger.warning(f"等待验证码超时: {email}")
|
||||
return None
|
||||
|
||||
def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
列出所有缓存的邮箱
|
||||
|
||||
Note:
|
||||
Tempmail.lol API 不支持列出所有邮箱,这里返回缓存的邮箱
|
||||
"""
|
||||
return list(self._email_cache.values())
|
||||
|
||||
def delete_email(self, email_id: str) -> bool:
|
||||
"""
|
||||
删除邮箱
|
||||
|
||||
Note:
|
||||
Tempmail.lol API 不支持删除邮箱,这里从缓存中移除
|
||||
"""
|
||||
# 从缓存中查找并移除
|
||||
emails_to_delete = []
|
||||
for email, info in self._email_cache.items():
|
||||
if info.get("token") == email_id:
|
||||
emails_to_delete.append(email)
|
||||
|
||||
for email in emails_to_delete:
|
||||
del self._email_cache[email]
|
||||
logger.info(f"从缓存中移除邮箱: {email}")
|
||||
|
||||
return len(emails_to_delete) > 0
|
||||
|
||||
def check_health(self) -> bool:
|
||||
"""检查 Tempmail.lol 服务是否可用"""
|
||||
try:
|
||||
response = self.http_client.get(
|
||||
f"{self.config['base_url']}/inbox/create",
|
||||
timeout=10
|
||||
)
|
||||
# 即使返回错误状态码也认为服务可用(只要可以连接)
|
||||
self.update_status(True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Tempmail.lol 健康检查失败: {e}")
|
||||
self.update_status(False, e)
|
||||
return False
|
||||
|
||||
def get_inbox(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取邮箱收件箱内容
|
||||
|
||||
Args:
|
||||
token: 邮箱 token
|
||||
|
||||
Returns:
|
||||
收件箱数据
|
||||
"""
|
||||
try:
|
||||
response = self.http_client.get(
|
||||
f"{self.config['base_url']}/inbox",
|
||||
params={"token": token},
|
||||
headers={"Accept": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"获取收件箱失败: {e}")
|
||||
return None
|
||||
|
||||
def wait_for_verification_code_with_callback(
|
||||
self,
|
||||
email: str,
|
||||
token: str,
|
||||
callback: callable = None,
|
||||
timeout: int = 120
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
等待验证码并支持回调函数
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
token: 邮箱 token
|
||||
callback: 回调函数,接收当前状态信息
|
||||
timeout: 超时时间
|
||||
|
||||
Returns:
|
||||
验证码或 None
|
||||
"""
|
||||
start_time = time.time()
|
||||
seen_ids = set()
|
||||
check_count = 0
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
check_count += 1
|
||||
|
||||
if callback:
|
||||
callback({
|
||||
"status": "checking",
|
||||
"email": email,
|
||||
"check_count": check_count,
|
||||
"elapsed_time": time.time() - start_time,
|
||||
})
|
||||
|
||||
try:
|
||||
data = self.get_inbox(token)
|
||||
if not data:
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
# 检查 inbox 是否过期
|
||||
if data is None or (isinstance(data, dict) and not data):
|
||||
if callback:
|
||||
callback({
|
||||
"status": "expired",
|
||||
"email": email,
|
||||
"message": "邮箱已过期"
|
||||
})
|
||||
return None
|
||||
|
||||
email_list = data.get("emails", []) if isinstance(data, dict) else []
|
||||
|
||||
for msg in email_list:
|
||||
msg_date = msg.get("date", 0)
|
||||
if not msg_date or msg_date in seen_ids:
|
||||
continue
|
||||
seen_ids.add(msg_date)
|
||||
|
||||
sender = str(msg.get("from", "")).lower()
|
||||
subject = str(msg.get("subject", ""))
|
||||
body = str(msg.get("body", ""))
|
||||
html = str(msg.get("html") or "")
|
||||
|
||||
content = "\n".join([sender, subject, body, html])
|
||||
|
||||
# 检查是否是 OpenAI 邮件
|
||||
if "openai" not in sender and "openai" not in content.lower():
|
||||
continue
|
||||
|
||||
# 提取验证码
|
||||
match = re.search(OTP_CODE_PATTERN, content)
|
||||
if match:
|
||||
code = match.group(1)
|
||||
if callback:
|
||||
callback({
|
||||
"status": "found",
|
||||
"email": email,
|
||||
"code": code,
|
||||
"message": "找到验证码"
|
||||
})
|
||||
return code
|
||||
|
||||
if callback and check_count % 5 == 0:
|
||||
callback({
|
||||
"status": "waiting",
|
||||
"email": email,
|
||||
"check_count": check_count,
|
||||
"message": f"已检查 {len(seen_ids)} 封邮件,等待验证码..."
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"检查邮件时出错: {e}")
|
||||
if callback:
|
||||
callback({
|
||||
"status": "error",
|
||||
"email": email,
|
||||
"error": str(e),
|
||||
"message": "检查邮件时出错"
|
||||
})
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
if callback:
|
||||
callback({
|
||||
"status": "timeout",
|
||||
"email": email,
|
||||
"message": "等待验证码超时"
|
||||
})
|
||||
return None
|
||||
Reference in New Issue
Block a user