Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/config/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class EmailServiceType(str, Enum):
DUCK_MAIL = "duck_mail"
FREEMAIL = "freemail"
IMAP_MAIL = "imap_mail"
CLOUD_MAIL = "cloud_mail"


# ============================================================================
Expand Down Expand Up @@ -143,7 +144,15 @@ class EmailServiceType(str, Enum):
"password": "",
"timeout": 30,
"max_retries": 3,
}
},
"cloud_mail": {
"base_url": "",
"admin_email": "",
"admin_password": "",
"default_domain": "",
"timeout": 30,
"max_retries": 3,
},
}

# ============================================================================
Expand Down
3 changes: 3 additions & 0 deletions src/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .duck_mail import DuckMailService
from .freemail import FreemailService
from .imap_mail import ImapMailService
from .cloud_mail import CloudMailService

# 注册服务
EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService)
Expand All @@ -26,6 +27,7 @@
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 (
Expand Down Expand Up @@ -59,6 +61,7 @@
'DuckMailService',
'FreemailService',
'ImapMailService',
'CloudMailService',
# Outlook 模块
'ProviderType',
'EmailMessage',
Expand Down
312 changes: 312 additions & 0 deletions src/services/cloud_mail.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/web/routes/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading