mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-06-26 09:42:03 +08:00
2
This commit is contained in:
32
src/services/__init__.py
Normal file
32
src/services/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
邮箱服务模块
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
BaseEmailService,
|
||||
EmailServiceError,
|
||||
EmailServiceStatus,
|
||||
EmailServiceFactory,
|
||||
create_email_service,
|
||||
EmailServiceType
|
||||
)
|
||||
from .tempmail import TempmailService
|
||||
from .outlook import OutlookService
|
||||
from .custom_domain import CustomDomainEmailService
|
||||
|
||||
# 注册服务
|
||||
EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService)
|
||||
EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService)
|
||||
EmailServiceFactory.register(EmailServiceType.CUSTOM_DOMAIN, CustomDomainEmailService)
|
||||
|
||||
__all__ = [
|
||||
'BaseEmailService',
|
||||
'EmailServiceError',
|
||||
'EmailServiceStatus',
|
||||
'EmailServiceFactory',
|
||||
'create_email_service',
|
||||
'EmailServiceType',
|
||||
'TempmailService',
|
||||
'OutlookService',
|
||||
'CustomDomainEmailService',
|
||||
]
|
||||
384
src/services/base.py
Normal file
384
src/services/base.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
邮箱服务抽象基类
|
||||
所有邮箱服务实现的基类
|
||||
"""
|
||||
|
||||
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)"
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
获取验证码
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
email_id: 邮箱服务中的 ID(如果需要)
|
||||
timeout: 超时时间(秒)
|
||||
pattern: 验证码正则表达式
|
||||
|
||||
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)
|
||||
528
src/services/custom_domain.py
Normal file
528
src/services/custom_domain.py
Normal file
@@ -0,0 +1,528 @@
|
||||
"""
|
||||
自定义域名邮箱服务实现
|
||||
基于 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 CustomDomainEmailService(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.CUSTOM_DOMAIN, 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:
|
||||
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
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
从自定义域名邮箱获取验证码
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
email_id: 邮箱 ID(如果不提供,从缓存中查找)
|
||||
timeout: 超时时间(秒)
|
||||
pattern: 验证码正则表达式
|
||||
|
||||
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
|
||||
|
||||
# 提取验证码
|
||||
match = re.search(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,
|
||||
}
|
||||
610
src/services/outlook.py
Normal file
610
src/services/outlook.py
Normal file
@@ -0,0 +1,610 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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] = {}
|
||||
|
||||
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)
|
||||
|
||||
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 = 120,
|
||||
pattern: str = OTP_CODE_PATTERN
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
从 Outlook 邮箱获取验证码
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
email_id: 未使用(对于 Outlook,email 就是标识)
|
||||
timeout: 超时时间(秒)
|
||||
pattern: 验证码正则表达式
|
||||
|
||||
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
|
||||
|
||||
logger.info(f"正在从 Outlook 邮箱 {email} 获取验证码...")
|
||||
|
||||
start_time = time.time()
|
||||
last_check_time = 0
|
||||
check_count = 0
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
check_count += 1
|
||||
|
||||
# 控制检查频率
|
||||
if time.time() - last_check_time < 3:
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
try:
|
||||
with self._imap_semaphore:
|
||||
with OutlookIMAPClient(
|
||||
account,
|
||||
host=self.config["imap_host"],
|
||||
port=self.config["imap_port"],
|
||||
timeout=10
|
||||
) as client:
|
||||
emails = client.get_recent_emails(count=10, only_unseen=True)
|
||||
|
||||
for mail in emails:
|
||||
# 检查是否是 OpenAI 相关邮件
|
||||
if not self._is_oai_mail(mail):
|
||||
continue
|
||||
|
||||
# 提取验证码
|
||||
content = f"{mail.get('from', '')} {mail.get('subject', '')} {mail.get('body', '')}"
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
code = match.group(1)
|
||||
logger.info(f"从 Outlook 邮箱 {email} 找到验证码: {code}")
|
||||
|
||||
# 可选:标记邮件为已读(避免重复获取)
|
||||
# 注意:这需要修改 IMAP 客户端的实现
|
||||
|
||||
self.update_status(True)
|
||||
return code
|
||||
|
||||
last_check_time = time.time()
|
||||
|
||||
if check_count % 5 == 0:
|
||||
logger.debug(f"检查 {email} 的验证码,已检查 {check_count} 次")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"检查 Outlook 邮箱 {email} 时出错: {e}")
|
||||
last_check_time = time.time()
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
logger.warning(f"等待验证码超时: {email}")
|
||||
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 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
|
||||
398
src/services/tempmail.py
Normal file
398
src/services/tempmail.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
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
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
从 Tempmail.lol 获取验证码
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
email_id: 邮箱 token(如果不提供,从缓存中查找)
|
||||
timeout: 超时时间(秒)
|
||||
pattern: 验证码正则表达式
|
||||
|
||||
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