feat: add cloud-mail service support

This commit is contained in:
zhoukailian
2026-03-26 20:07:21 +08:00
parent ae089ee707
commit a890bc7f2b
14 changed files with 801 additions and 9 deletions

View File

@@ -38,6 +38,7 @@ class EmailServiceType(str, Enum):
DUCK_MAIL = "duck_mail"
FREEMAIL = "freemail"
IMAP_MAIL = "imap_mail"
CLOUD_MAIL = "cloud_mail"
# ============================================================================
@@ -143,7 +144,15 @@ EMAIL_SERVICE_DEFAULTS = {
"password": "",
"timeout": 30,
"max_retries": 3,
}
},
"cloud_mail": {
"base_url": "",
"admin_email": "",
"admin_password": "",
"default_domain": "",
"timeout": 30,
"max_retries": 3,
},
}
# ============================================================================

View File

@@ -17,6 +17,7 @@ from .temp_mail import TempMailService
from .duck_mail import DuckMailService
from .freemail import FreemailService
from .imap_mail import ImapMailService
from .cloud_mail import CloudMailService
# 注册服务
EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService)
@@ -26,6 +27,7 @@ EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService)
EmailServiceFactory.register(EmailServiceType.DUCK_MAIL, DuckMailService)
EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService)
EmailServiceFactory.register(EmailServiceType.IMAP_MAIL, ImapMailService)
EmailServiceFactory.register(EmailServiceType.CLOUD_MAIL, CloudMailService)
# 导出 Outlook 模块的额外内容
from .outlook.base import (
@@ -59,6 +61,7 @@ __all__ = [
'DuckMailService',
'FreemailService',
'ImapMailService',
'CloudMailService',
# Outlook 模块
'ProviderType',
'EmailMessage',

312
src/services/cloud_mail.py Normal file
View File

@@ -0,0 +1,312 @@
"""
Cloud Mail 邮箱服务实现
基于 maillab/cloud-mail 的 public API
"""
import logging
import random
import string
import time
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from .base import BaseEmailService, EmailServiceError, EmailServiceType, RateLimitedEmailServiceError
from ..config.constants import OTP_CODE_PATTERN
from ..core.http_client import HTTPClient, RequestConfig
logger = logging.getLogger(__name__)
OTP_SENT_AT_TOLERANCE_SECONDS = 2
class CloudMailService(BaseEmailService):
"""Cloud Mail 邮箱服务"""
def __init__(self, config: Dict[str, Any] = None, name: str = None):
super().__init__(EmailServiceType.CLOUD_MAIL, name)
required_keys = ["base_url", "admin_email", "admin_password", "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 = {
"timeout": 30,
"max_retries": 3,
"password_length": 16,
}
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=None, config=http_config)
self._email_cache: Dict[str, Dict[str, Any]] = {}
def _build_headers(
self,
token: Optional[str] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, str]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
}
if token:
headers["Authorization"] = token
if extra_headers:
headers.update(extra_headers)
return headers
def _unwrap_result(self, payload: Any) -> Any:
if not isinstance(payload, dict) or "code" not in payload:
return payload
if payload.get("code") != 200:
raise EmailServiceError(str(payload.get("message") or "Cloud Mail API 返回失败"))
return payload.get("data")
def _make_request(
self,
method: str,
path: str,
token: Optional[str] = None,
**kwargs,
) -> Any:
url = f"{self.config['base_url']}/api{path}"
kwargs["headers"] = self._build_headers(token=token, extra_headers=kwargs.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]}"
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:
payload = response.json()
except Exception:
payload = {"raw_response": response.text}
data = self._unwrap_result(payload)
return data
except Exception as e:
self.update_status(False, e)
if isinstance(e, EmailServiceError):
raise
raise EmailServiceError(f"请求失败: {method} {path} - {e}")
def _get_public_token(self) -> str:
data = self._make_request(
"POST",
"/public/genToken",
json={
"email": self.config["admin_email"],
"password": self.config["admin_password"],
},
)
if isinstance(data, dict):
token = str(data.get("token") or "").strip()
else:
token = str(data or "").strip()
if not token:
raise EmailServiceError("Cloud Mail 未返回 public token")
return token
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(8, int(self.config.get("password_length") or 16))
alphabet = string.ascii_letters + string.digits
return "".join(random.choices(alphabet, k=length))
def _parse_message_time(self, value: Any) -> Optional[float]:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
timestamp = float(value)
else:
text = str(value).strip()
if not text:
return None
try:
timestamp = float(text)
except ValueError:
normalized = text.replace("Z", "+00:00")
if "T" not in normalized and "+" not in normalized[10:] and normalized.count(":") >= 2:
normalized = normalized.replace(" ", "T", 1) + "+00:00"
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
timestamp = parsed.astimezone(timezone.utc).timestamp()
while timestamp > 1e11:
timestamp /= 1000.0
return timestamp if timestamp > 0 else None
def _get_received_timestamp(self, mail: Dict[str, Any]) -> Optional[float]:
for field_name in ("createTime", "createdAt", "receivedAt", "timestamp", "time"):
timestamp = self._parse_message_time(mail.get(field_name))
if timestamp is not None:
return timestamp
return None
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 = str(request_config.get("password") or self._generate_password())
token = self._get_public_token()
self._make_request(
"POST",
"/public/addUser",
token=token,
json={
"list": [{
"email": address,
"password": password,
}]
},
)
email_info = {
"email": address,
"password": password,
"service_id": address,
"id": address,
"created_at": time.time(),
}
self._email_cache[address.lower()] = email_info
self.update_status(True)
logger.info(f"成功创建 Cloud Mail 邮箱: {address}")
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]:
logger.info(f"正在从 Cloud Mail 邮箱 {email} 获取验证码...")
start_time = time.time()
seen_mail_ids: set = set()
while time.time() - start_time < timeout:
try:
token = self._get_public_token()
mails = self._make_request(
"POST",
"/public/emailList",
token=token,
json={
"toEmail": email,
"num": 1,
"size": 20,
},
)
if isinstance(mails, dict) and isinstance(mails.get("list"), list):
mails = mails["list"]
if not isinstance(mails, list):
time.sleep(3)
continue
for mail in mails:
msg_timestamp = self._get_received_timestamp(mail)
if otp_sent_at is not None:
min_allowed_timestamp = otp_sent_at - OTP_SENT_AT_TOLERANCE_SECONDS
if msg_timestamp is None or msg_timestamp <= min_allowed_timestamp:
continue
mail_id = mail.get("emailId") or mail.get("id")
if mail_id in seen_mail_ids:
continue
if mail_id is not None:
seen_mail_ids.add(mail_id)
sender = str(mail.get("sendEmail") or mail.get("sender") or "")
sender_name = str(mail.get("sendName") or mail.get("name") or "")
subject = str(mail.get("subject") or "")
text_body = str(mail.get("text") or "")
content = str(mail.get("content") or "")
search_text = "\n".join(
part for part in [sender, sender_name, subject, text_body, content] if part
).strip()
if "openai" not in search_text.lower():
continue
code = self._extract_otp_from_text(search_text, pattern)
if code:
self.update_status(True)
logger.info(f"从 Cloud Mail 邮箱 {email} 找到验证码: {code}")
return code
except Exception as e:
logger.debug(f"检查 Cloud Mail 邮件时出错: {e}")
time.sleep(3)
logger.warning(f"等待 Cloud Mail 验证码超时: {email}")
return None
def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
return list(self._email_cache.values())
def delete_email(self, email_id: str) -> bool:
self._email_cache.pop(str(email_id).strip().lower(), None)
self.update_status(True)
return True
def check_health(self) -> bool:
try:
self._get_public_token()
self.update_status(True)
return True
except Exception as e:
logger.warning(f"Cloud Mail 健康检查失败: {e}")
self.update_status(False, e)
return False

View File

@@ -1540,6 +1540,7 @@ def _build_inbox_config(db, service_type, email: str) -> dict:
EST.DUCK_MAIL: "duck_mail",
EST.FREEMAIL: "freemail",
EST.IMAP_MAIL: "imap_mail",
EST.CLOUD_MAIL: "cloud_mail",
EST.OUTLOOK: "outlook",
}
db_type = type_map.get(service_type)

View File

@@ -84,7 +84,7 @@ class OutlookBatchImportResponse(BaseModel):
# ============== Helper Functions ==============
# 敏感字段列表,返回响应时需要过滤
SENSITIVE_FIELDS = {'password', 'api_key', 'refresh_token', 'access_token', 'admin_token'}
SENSITIVE_FIELDS = {'password', 'api_key', 'refresh_token', 'access_token', 'admin_token', 'admin_password'}
def filter_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]:
"""过滤敏感配置信息"""
@@ -147,6 +147,7 @@ async def get_email_services_stats():
'duck_mail_count': 0,
'freemail_count': 0,
'imap_mail_count': 0,
'cloud_mail_count': 0,
'tempmail_available': True, # 临时邮箱始终可用
'enabled_count': enabled_count
}
@@ -164,6 +165,8 @@ async def get_email_services_stats():
stats['freemail_count'] = count
elif service_type == 'imap_mail':
stats['imap_mail_count'] = count
elif service_type == 'cloud_mail':
stats['cloud_mail_count'] = count
return stats
@@ -235,6 +238,17 @@ async def get_service_types():
{"name": "domain", "label": "邮箱域名", "required": False, "placeholder": "example.com"},
]
},
{
"value": "cloud_mail",
"label": "Cloud Mail",
"description": "cloud-mail 自部署邮箱服务,使用公开 API",
"config_fields": [
{"name": "base_url", "label": "站点地址", "required": True, "placeholder": "https://mail.example.com"},
{"name": "admin_email", "label": "管理员邮箱", "required": True, "placeholder": "admin@example.com"},
{"name": "admin_password", "label": "管理员密码", "required": True, "secret": True},
{"name": "default_domain", "label": "默认域名", "required": True, "placeholder": "mail.example.com"},
]
},
{
"value": "imap_mail",
"label": "IMAP 邮箱",

View File

@@ -285,6 +285,9 @@ def _normalize_email_service_config(
elif service_type == EmailServiceType.DUCK_MAIL:
if 'domain' in normalized and 'default_domain' not in normalized:
normalized['default_domain'] = normalized.pop('domain')
elif service_type == EmailServiceType.CLOUD_MAIL:
if 'domain' in normalized and 'default_domain' not in normalized:
normalized['default_domain'] = normalized.pop('domain')
if proxy_url and 'proxy_url' not in normalized:
normalized['proxy_url'] = proxy_url
@@ -527,6 +530,10 @@ def _build_email_service_candidates(
append_database_candidates("imap_mail")
if not candidates:
raise ValueError("没有可用的 IMAP 邮箱服务,请先在邮箱服务中添加")
elif service_type == EmailServiceType.CLOUD_MAIL:
append_database_candidates("cloud_mail")
if not candidates:
raise ValueError("没有可用的 Cloud Mail 邮箱服务,请先在邮箱服务页面添加服务")
else:
append_candidate(service_type, email_service_config or {})
@@ -1783,6 +1790,11 @@ async def get_available_email_services():
"available": False,
"count": 0,
"services": []
},
"cloud_mail": {
"available": False,
"count": 0,
"services": []
}
}
@@ -1911,6 +1923,24 @@ async def get_available_email_services():
result["imap_mail"]["count"] = len(imap_mail_services)
result["imap_mail"]["available"] = len(imap_mail_services) > 0
cloud_mail_services = db.query(EmailServiceModel).filter(
EmailServiceModel.service_type == "cloud_mail",
EmailServiceModel.enabled == True
).order_by(EmailServiceModel.priority.asc()).all()
for service in cloud_mail_services:
config = service.config or {}
result["cloud_mail"]["services"].append({
"id": service.id,
"name": service.name,
"type": "cloud_mail",
"default_domain": config.get("default_domain"),
"priority": service.priority
})
result["cloud_mail"]["count"] = len(cloud_mail_services)
result["cloud_mail"]["available"] = len(cloud_mail_services) > 0
return result