feat: codex-register with Sub2API增强 + Playwright引擎
Some checks are pending
Docker Image CI / build-and-push-image (push) Waiting to run

This commit is contained in:
2026-03-22 00:24:16 +08:00
commit 0f9948ffc3
91 changed files with 29942 additions and 0 deletions

73
src/services/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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,
}

View File

@@ -0,0 +1,8 @@
"""
Outlook 邮箱服务模块
支持多种 IMAP/API 连接方式,自动故障切换
"""
from .service import OutlookService
__all__ = ['OutlookService']

View 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})"

View 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,
}

View 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

View 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(),
}

View 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)

View 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__()

View 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 返回 401client_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

View 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

View 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

View 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 tokenIMAP_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}")

View 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)

View 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, # 对于 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 = None,
pattern: str = OTP_CODE_PATTERN,
otp_sent_at: Optional[float] = None,
) -> Optional[str]:
"""
从 Outlook 邮箱获取验证码
Args:
email: 邮箱地址
email_id: 未使用(对于 Outlookemail 就是标识)
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}sOTP发送时间: {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:
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 _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
View 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
View 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