mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-05-07 04:52:54 +08:00
569 lines
19 KiB
Python
569 lines
19 KiB
Python
"""
|
||
自定义域名邮箱服务实现
|
||
基于 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, RateLimitedEmailServiceError
|
||
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]}"
|
||
|
||
retry_after = None
|
||
if response.status_code == 429:
|
||
retry_after_header = response.headers.get("Retry-After")
|
||
if retry_after_header:
|
||
try:
|
||
retry_after = max(1, int(retry_after_header))
|
||
except ValueError:
|
||
retry_after = None
|
||
error = RateLimitedEmailServiceError(error_msg, retry_after=retry_after)
|
||
else:
|
||
error = EmailServiceError(error_msg)
|
||
|
||
self.update_status(False, error)
|
||
raise error
|
||
|
||
# 解析响应
|
||
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,
|
||
}
|