diff --git a/src/config/constants.py b/src/config/constants.py index 9e787a2e..50135b20 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -37,6 +37,7 @@ class EmailServiceType(str, Enum): TEMP_MAIL = "temp_mail" DUCK_MAIL = "duck_mail" FREEMAIL = "freemail" + CLOUDMAIL = "cloudmail" IMAP_MAIL = "imap_mail" @@ -132,6 +133,16 @@ class EmailServiceType(str, Enum): "timeout": 30, "max_retries": 3, }, + "cloudmail": { + "base_url": "", + "login_email": "", + "login_password": "", + "default_domain": "", + "proxy_url": None, + "poll_interval": 3, + "timeout": 30, + "max_retries": 3, + }, "imap_mail": { "host": "", "port": 993, diff --git a/src/services/__init__.py b/src/services/__init__.py index ad29d3e5..c73366c3 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -16,6 +16,7 @@ from .temp_mail import TempMailService from .duck_mail import DuckMailService from .freemail import FreemailService +from .cloudmail import CloudMailService from .imap_mail import ImapMailService # 注册服务 @@ -25,6 +26,7 @@ EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService) EmailServiceFactory.register(EmailServiceType.DUCK_MAIL, DuckMailService) EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService) +EmailServiceFactory.register(EmailServiceType.CLOUDMAIL, CloudMailService) EmailServiceFactory.register(EmailServiceType.IMAP_MAIL, ImapMailService) # 导出 Outlook 模块的额外内容 @@ -58,6 +60,7 @@ 'TempMailService', 'DuckMailService', 'FreemailService', + 'CloudMailService', 'ImapMailService', # Outlook 模块 'ProviderType', diff --git a/src/services/cloudmail.py b/src/services/cloudmail.py new file mode 100644 index 00000000..08a2592c --- /dev/null +++ b/src/services/cloudmail.py @@ -0,0 +1,394 @@ +""" +CloudMail 邮箱服务实现 +基于 CloudMail Web API +""" + +import logging +import random +import re +import string +import time +from datetime import datetime, timezone +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 CloudMailService(BaseEmailService): + """CloudMail 邮箱服务。""" + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + super().__init__(EmailServiceType.CLOUDMAIL, name or "cloudmail_service") + + required_keys = ["base_url", "login_email", "login_password"] + 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, + "proxy_url": None, + "default_domain": "", + "poll_interval": 3, + "login_email": "", + "login_password": "", + } + self.config = {**default_config, **(config or {})} + self.config["base_url"] = str(self.config["base_url"]).rstrip("/") + + 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._domains: List[str] = [] + self._accounts_by_id: Dict[str, Dict[str, Any]] = {} + self._accounts_by_email: Dict[str, Dict[str, Any]] = {} + self._authorization_jwt: Optional[str] = None + + def _build_headers(self, authorization: Optional[str] = None) -> Dict[str, str]: + headers = { + "accept": "application/json, text/plain, */*", + "accept-language": "zh", + } + if authorization is not None: + headers["authorization"] = authorization + return headers + + def _make_request( + self, + method: str, + path: str, + authorization: Optional[str] = None, + **kwargs, + ) -> Any: + url = f"{self.config['base_url']}{path}" + headers = self._build_headers(authorization=authorization) + extra_headers = kwargs.pop("headers", None) or {} + headers.update(extra_headers) + + try: + response = self.http_client.request(method, url, headers=headers, **kwargs) + + if response.status_code >= 400: + message = f"请求失败: {response.status_code}" + try: + message = f"{message} - {response.text[:300]}" + except Exception: + pass + raise EmailServiceError(message) + + try: + data = response.json() + except Exception: + return {"raw_response": response.text} + + if isinstance(data, dict) and data.get("code") not in (None, 200): + raise EmailServiceError(f"接口返回异常: {data}") + + return data + except Exception as e: + self.update_status(False, e) + if isinstance(e, EmailServiceError): + raise + raise EmailServiceError(f"请求失败: {method} {path} - {e}") + + def _get_authorization_jwt(self, refresh: bool = False) -> str: + if not refresh and self._authorization_jwt: + return self._authorization_jwt + + token = self.login( + self.config["login_email"], + self.config["login_password"], + ) + self._authorization_jwt = token + return token + + def _cache_account(self, account_info: Dict[str, Any]) -> None: + account_id = str(account_info.get("accountId") 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(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 _parse_time(self, value: Any) -> Optional[float]: + if not value: + return None + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip() + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc).timestamp() + except Exception: + pass + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc).timestamp() + except Exception: + continue + return None + + def _message_timestamp(self, message: Dict[str, Any]) -> float: + created_at = self._parse_time(message.get("createTime")) + if created_at is not None: + return created_at + return 0.0 + + def _normalize_message(self, message: Dict[str, Any]) -> Dict[str, Any]: + normalized = dict(message or {}) + created_at = self._message_timestamp(normalized) + if created_at > 0: + local_time = datetime.fromtimestamp(created_at, tz=timezone.utc).astimezone() + normalized.setdefault("received_at", local_time.isoformat()) + normalized.setdefault("created_at", local_time.isoformat()) + + message_id = str(normalized.get("emailId") or normalized.get("id") or "").strip() + if message_id: + normalized.setdefault("id", message_id) + + sender = str(normalized.get("sendEmail") or normalized.get("from") or "").strip() + if sender: + normalized.setdefault("from", sender) + + return normalized + + def _generate_local_part(self, length: int = 10) -> str: + first = random.choice(string.ascii_lowercase) + rest = "".join(random.choices(string.ascii_lowercase + string.digits, k=max(1, length - 1))) + return f"{first}{rest}" + + def login(self, email: str, password: str) -> str: + data = self._make_request( + "POST", + "/api/login", + json={"email": email, "password": password}, + headers={ + "content-type": "application/json", + "cache-control": "no-cache", + "pragma": "no-cache", + }, + ) + token = ((data or {}).get("data") or {}).get("token") + if not token: + raise EmailServiceError(f"登录返回异常: {data}") + return str(token) + + def get_domain_list(self) -> List[str]: + data = self._make_request( + "GET", + "/api/setting/websiteConfig", + authorization="null", + headers={ + "cache-control": "no-cache", + "pragma": "no-cache", + }, + ) + domains = ((data or {}).get("data") or {}).get("domainList") or [] + return [str(domain).strip() for domain in domains if str(domain).strip()] + + def _ensure_domains(self) -> None: + if not self._domains: + try: + self._domains = self.get_domain_list() + except Exception as e: + logger.warning(f"获取 CloudMail 域名列表失败: {e}") + + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + req_config = config or {} + self._ensure_domains() + + domain = str(req_config.get("domain") or self.config.get("default_domain") or "").strip() + if not domain and self._domains: + domain = self._domains[0] + if domain and not domain.startswith("@"): + domain = f"@{domain}" + + email = str(req_config.get("email") or "").strip() + if not email: + prefix = str(req_config.get("name") or req_config.get("prefix") or self._generate_local_part()).strip() + if not domain: + raise EmailServiceError("未配置可用域名,无法创建 CloudMail 邮箱") + email = f"{prefix}{domain}" + + data = self._make_request( + "POST", + "/api/account/add", + authorization=self._get_authorization_jwt(), + json={"email": email, "token": ""}, + headers={"content-type": "application/json"}, + ) + + account_data = (data or {}).get("data") or {} + account_id = account_data.get("accountId") + email_address = account_data.get("email") or email + if not account_id or not email_address: + raise EmailServiceError(f"创建邮箱失败,返回数据不完整: {data}") + + email_info = { + "id": str(account_id), + "service_id": str(account_id), + "accountId": str(account_id), + "email": str(email_address), + "created_at": time.time(), + "raw_data": account_data, + } + self._cache_account(email_info) + self.update_status(True) + return email_info + + def get_email_messages(self, email_id: str, **kwargs) -> List[Dict[str, Any]]: + account = self._get_account(email_id=email_id) or self._get_account(email=email_id) + if not account: + return [] + + account_id = account.get("accountId") or account.get("service_id") + if not account_id: + return [] + + params = { + "accountId": str(account_id), + "allReceive": str(kwargs.get("allReceive", 0)), + "emailId": str(kwargs.get("emailId", 0)), + "timeSort": str(kwargs.get("timeSort", 0)), + "size": str(kwargs.get("size", 20)), + "type": str(kwargs.get("type", 0)), + } + + data = self._make_request( + "GET", + "/api/email/list", + authorization=self._get_authorization_jwt(), + params=params, + headers={ + "cache-control": "no-cache", + "pragma": "no-cache", + }, + ) + payload = (data or {}).get("data") or {} + messages = payload.get("list") or [] + if not isinstance(messages, list): + return [] + return [self._normalize_message(message) for message in messages] + + 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 = self._get_account(email=email, email_id=email_id) + if not account: + logger.warning(f"CloudMail 未找到邮箱缓存: {email}, {email_id}") + return None + + start_time = time.time() + seen_message_ids = set() + poll_interval = max(1, int(self.config.get("poll_interval") or 3)) + + while time.time() - start_time < timeout: + try: + messages = self.get_email_messages(account.get("accountId") or account.get("service_id")) + messages = sorted( + messages, + key=self._message_timestamp, + reverse=True, + ) + + for message in messages: + message_id = str(message.get("emailId") or message.get("id") or "").strip() + if not message_id or message_id in seen_message_ids: + continue + + created_at = self._message_timestamp(message) + if otp_sent_at and created_at and created_at + 1 < otp_sent_at: + continue + + seen_message_ids.add(message_id) + + sender = str(message.get("sendEmail") or message.get("from") or "") + subject = str(message.get("subject") or "") + text = str(message.get("text") or "") + content = str(message.get("content") or "") + merged = "\n".join(part for part in [sender, subject, text, content] if part) + merged_lower = merged.lower() + + if "openai" not in merged_lower and "chatgpt" not in merged_lower: + continue + + match = re.search(pattern, merged) + if match: + self.update_status(True) + return match.group(1) + except Exception as e: + logger.debug(f"CloudMail 轮询验证码失败: {e}") + + time.sleep(poll_interval) + + logger.warning(f"等待 CloudMail 验证码超时: {email}") + 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 = self._get_account(email_id=email_id) or self._get_account(email=email_id) + if not account: + return False + + account_id = account.get("accountId") or account.get("service_id") + if not account_id: + return False + + try: + self._make_request( + "DELETE", + "/api/account/delete", + authorization=self._get_authorization_jwt(), + params={"accountId": str(account_id)}, + ) + self._accounts_by_id.pop(str(account_id), None) + self._accounts_by_email.pop(str(account.get("email") or "").lower(), None) + self.update_status(True) + return True + except Exception as e: + logger.warning(f"CloudMail 删除邮箱失败: {e}") + self.update_status(False, e) + return False + + def check_health(self) -> bool: + try: + self.get_domain_list() + self.update_status(True) + return True + except Exception as e: + logger.warning(f"CloudMail 健康检查失败: {e}") + self.update_status(False, e) + return False diff --git a/src/web/routes/email.py b/src/web/routes/email.py index 69317239..d3816fc2 100644 --- a/src/web/routes/email.py +++ b/src/web/routes/email.py @@ -92,6 +92,7 @@ class OutlookBatchImportResponse(BaseModel): 'admin_token', 'admin_password', 'custom_auth', + 'login_password', } def filter_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]: @@ -154,6 +155,7 @@ async def get_email_services_stats(): 'temp_mail_count': 0, 'duck_mail_count': 0, 'freemail_count': 0, + 'cloudmail_count': 0, 'imap_mail_count': 0, 'tempmail_available': True, # 临时邮箱始终可用 'enabled_count': enabled_count @@ -170,6 +172,8 @@ async def get_email_services_stats(): stats['duck_mail_count'] = count elif service_type == 'freemail': stats['freemail_count'] = count + elif service_type == 'cloudmail': + stats['cloudmail_count'] = count elif service_type == 'imap_mail': stats['imap_mail_count'] = count @@ -244,6 +248,18 @@ async def get_service_types(): {"name": "domain", "label": "邮箱域名", "required": False, "placeholder": "example.com"}, ] }, + { + "value": "cloudmail", + "label": "CloudMail", + "description": "CloudMail Web API 邮箱服务,使用后台账户创建临时邮箱", + "config_fields": [ + {"name": "base_url", "label": "站点地址", "required": True, "placeholder": "https://mail.example.com"}, + {"name": "login_email", "label": "登录邮箱", "required": True}, + {"name": "login_password", "label": "登录密码", "required": True, "secret": True}, + {"name": "default_domain", "label": "默认域名", "required": False, "placeholder": "example.com"}, + {"name": "poll_interval", "label": "轮询间隔", "required": False, "default": 3}, + ] + }, { "value": "imap_mail", "label": "IMAP 邮箱", diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index b2cdcd5c..e131625a 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -1148,6 +1148,11 @@ async def get_available_email_services(): "count": 0, "services": [] }, + "cloudmail": { + "available": False, + "count": 0, + "services": [] + }, "imap_mail": { "available": False, "count": 0, @@ -1261,6 +1266,25 @@ async def get_available_email_services(): result["freemail"]["count"] = len(freemail_services) result["freemail"]["available"] = len(freemail_services) > 0 + cloudmail_services = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "cloudmail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).all() + + for service in cloudmail_services: + config = service.config or {} + result["cloudmail"]["services"].append({ + "id": service.id, + "name": service.name, + "type": "cloudmail", + "default_domain": config.get("default_domain"), + "login_email": config.get("login_email"), + "priority": service.priority + }) + + result["cloudmail"]["count"] = len(cloudmail_services) + result["cloudmail"]["available"] = len(cloudmail_services) > 0 + imap_mail_services = db.query(EmailServiceModel).filter( EmailServiceModel.service_type == "imap_mail", EmailServiceModel.enabled == True diff --git a/static/js/app.js b/static/js/app.js index 5a00e755..d1692311 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -26,7 +26,8 @@ let availableServices = { moe_mail: { available: false, services: [] }, temp_mail: { available: false, services: [] }, duck_mail: { available: false, services: [] }, - freemail: { available: false, services: [] } + freemail: { available: false, services: [] }, + cloudmail: { available: false, services: [] } }; // WebSocket 相关变量 @@ -383,6 +384,23 @@ function updateEmailServiceOptions() { select.appendChild(optgroup); } + + // CloudMail + if (availableServices.cloudmail && availableServices.cloudmail.available) { + const optgroup = document.createElement('optgroup'); + optgroup.label = `☁️ CloudMail (${availableServices.cloudmail.count} 个服务)`; + + availableServices.cloudmail.services.forEach(service => { + const option = document.createElement('option'); + option.value = `cloudmail:${service.id}`; + option.textContent = service.name + (service.default_domain ? ` (@${service.default_domain})` : ''); + option.dataset.type = 'cloudmail'; + option.dataset.serviceId = service.id; + optgroup.appendChild(option); + }); + + select.appendChild(optgroup); + } } // 处理邮箱服务切换 @@ -433,6 +451,11 @@ function handleServiceChange(e) { if (service) { addLog('info', `[系统] 已选择 Freemail 服务: ${service.name}`); } + } else if (type === 'cloudmail') { + const service = availableServices.cloudmail.services.find(s => s.id == id); + if (service) { + addLog('info', `[系统] 已选择 CloudMail 服务: ${service.name}`); + } } } diff --git a/static/js/email_services.js b/static/js/email_services.js index 463b1a80..8ebaa989 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -4,7 +4,7 @@ // 状态 let outlookServices = []; -let customServices = []; // 合并 moe_mail + temp_mail + duck_mail + freemail + imap_mail +let customServices = []; // 合并 moe_mail + temp_mail + duck_mail + freemail + cloudmail + imap_mail let selectedOutlook = new Set(); let selectedCustom = new Set(); @@ -52,6 +52,7 @@ const elements = { addTempmailFields: document.getElementById('add-tempmail-fields'), addDuckmailFields: document.getElementById('add-duckmail-fields'), addFreemailFields: document.getElementById('add-freemail-fields'), + addCloudmailFields: document.getElementById('add-cloudmail-fields'), addImapFields: document.getElementById('add-imap-fields'), // 编辑自定义域名模态框 @@ -63,6 +64,7 @@ const elements = { editTempmailFields: document.getElementById('edit-tempmail-fields'), editDuckmailFields: document.getElementById('edit-duckmail-fields'), editFreemailFields: document.getElementById('edit-freemail-fields'), + editCloudmailFields: document.getElementById('edit-cloudmail-fields'), editImapFields: document.getElementById('edit-imap-fields'), editCustomTypeBadge: document.getElementById('edit-custom-type-badge'), editCustomSubTypeHidden: document.getElementById('edit-custom-sub-type-hidden'), @@ -79,6 +81,7 @@ const CUSTOM_SUBTYPE_LABELS = { tempmail: '📮 TempMail(自部署 Cloudflare Worker)', duckmail: '🦆 DuckMail(DuckMail API)', freemail: 'Freemail(自部署 Cloudflare Worker)', + cloudmail: '☁️ CloudMail(自部署 Cloudflare Worker)', imap: '📧 IMAP 邮箱(Gmail/QQ/163等)' }; @@ -185,6 +188,7 @@ function switchAddSubType(subType) { elements.addTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; elements.addDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; elements.addFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; + elements.addCloudmailFields.style.display = subType === 'cloudmail' ? '' : 'none'; elements.addImapFields.style.display = subType === 'imap' ? '' : 'none'; } @@ -195,6 +199,7 @@ function switchEditSubType(subType) { elements.editTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; elements.editDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; elements.editFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; + elements.editCloudmailFields.style.display = subType === 'cloudmail' ? '' : 'none'; elements.editImapFields.style.display = subType === 'imap' ? '' : 'none'; elements.editCustomTypeBadge.textContent = CUSTOM_SUBTYPE_LABELS[subType] || CUSTOM_SUBTYPE_LABELS.moemail; } @@ -204,7 +209,7 @@ async function loadStats() { try { const data = await api.get('/email-services/stats'); elements.outlookCount.textContent = data.outlook_count || 0; - elements.customCount.textContent = (data.custom_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0) + (data.freemail_count || 0) + (data.imap_mail_count || 0); + elements.customCount.textContent = (data.custom_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0) + (data.freemail_count || 0) + (data.cloudmail_count || 0) + (data.imap_mail_count || 0); elements.tempmailStatus.textContent = data.tempmail_available ? '可用' : '不可用'; elements.totalEnabled.textContent = data.enabled_count || 0; } catch (error) { @@ -289,6 +294,9 @@ function getCustomServiceTypeBadge(subType) { if (subType === 'freemail') { return 'Freemail'; } + if (subType === 'cloudmail') { + return 'CloudMail'; + } return 'IMAP'; } @@ -298,6 +306,20 @@ function getCustomServiceAddress(service) { const emailAddr = service.config?.email || ''; return `${escapeHtml(host)}