This commit is contained in:
cnlimiter
2026-03-14 16:51:57 +08:00
parent dc1334fbab
commit 9d3099fcd8
35 changed files with 9490 additions and 0 deletions

32
src/services/__init__.py Normal file
View 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
View 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)

View 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
View 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, # 对于 Outlookservice_id 就是邮箱地址
"account": {
"email": account.email,
"has_oauth": account.has_oauth()
}
}
logger.info(f"选择 Outlook 账户: {account.email}")
self.update_status(True)
return email_info
def get_verification_code(
self,
email: str,
email_id: str = None,
timeout: int = 120,
pattern: str = OTP_CODE_PATTERN
) -> Optional[str]:
"""
从 Outlook 邮箱获取验证码
Args:
email: 邮箱地址
email_id: 未使用(对于 Outlookemail 就是标识)
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:
FalseOutlook 不支持删除账户)
"""
logger.warning(f"Outlook 服务不支持删除账户: {email_id}")
return False
def check_health(self) -> bool:
"""检查 Outlook 服务是否可用"""
if not self.accounts:
self.update_status(False, EmailServiceError("没有配置的账户"))
return False
# 测试第一个账户的连接
test_account = self.accounts[0]
try:
with self._imap_semaphore:
with OutlookIMAPClient(
test_account,
host=self.config["imap_host"],
port=self.config["imap_port"],
timeout=10
) as client:
# 尝试列出邮箱(快速测试)
client._conn.select("INBOX", readonly=True)
self.update_status(True)
return True
except Exception as e:
logger.warning(f"Outlook 健康检查失败 ({test_account.email}): {e}")
self.update_status(False, e)
return False
def _is_oai_mail(self, mail: Dict[str, Any]) -> bool:
"""判断是否为 OpenAI 相关邮件"""
combined = f"{mail.get('from', '')} {mail.get('subject', '')} {mail.get('body', '')}".lower()
keywords = ["openai", "chatgpt", "verification", "验证码", "code"]
return any(keyword in combined for keyword in keywords)
def 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
View 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