From 3ab48fc79cbac31fdb90af12840fd6e97d63b480 Mon Sep 17 00:00:00 2001 From: xiaoge Date: Fri, 27 Mar 2026 04:53:41 +0800 Subject: [PATCH 1/4] feat: Implement YYDS Mail service with email creation and verification functionalities - Added YYDSMailService class for handling email operations via YYDS Mail REST API. - Implemented methods for creating temporary emails, retrieving verification codes, and managing email accounts. - Introduced unit tests for the YYDS Mail service, including tests for email creation and verification code retrieval. - Updated email service routes to include YYDS Mail in available services and configurations. --- codex_register.spec | 1 + src/config/constants.py | 8 + src/config/settings.py | 58 ++- src/services/__init__.py | 3 + src/services/yyds_mail.py | 448 ++++++++++++++++++++ src/web/routes/accounts.py | 10 + src/web/routes/email.py | 68 ++- src/web/routes/payment.py | 15 + src/web/routes/registration.py | 43 +- src/web/routes/settings.py | 45 +- static/js/app.js | 37 +- static/js/email_services.js | 79 +++- static/js/utils.js | 1 + templates/accounts.html | 1 + templates/email_services.html | 70 ++- templates/index.html | 1 + tests/test_email_service_duckmail_routes.py | 4 + tests/test_email_service_yyds_routes.py | 87 ++++ tests/test_yyds_mail_service.py | 147 +++++++ 19 files changed, 1076 insertions(+), 50 deletions(-) create mode 100644 src/services/yyds_mail.py create mode 100644 tests/test_email_service_yyds_routes.py create mode 100644 tests/test_yyds_mail_service.py diff --git a/codex_register.spec b/codex_register.spec index 40af3d7a..92d30f11 100644 --- a/codex_register.spec +++ b/codex_register.spec @@ -84,6 +84,7 @@ a = Analysis( 'src.core.utils', 'src.services.base', 'src.services.tempmail', + 'src.services.yyds_mail', 'src.services.moe_mail', 'src.services.outlook', 'src.services.outlook.account', diff --git a/src/config/constants.py b/src/config/constants.py index 18e2964d..7e9a7a38 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -32,6 +32,7 @@ class TaskStatus(str, Enum): class EmailServiceType(str, Enum): """邮箱服务类型""" TEMPMAIL = "tempmail" + YYDS_MAIL = "yyds_mail" OUTLOOK = "outlook" MOE_MAIL = "moe_mail" TEMP_MAIL = "temp_mail" @@ -106,6 +107,13 @@ class EmailServiceType(str, Enum): "timeout": 30, "max_retries": 3, }, + "yyds_mail": { + "base_url": "https://maliapi.215.im/v1", + "api_key": "", + "default_domain": "", + "timeout": 30, + "max_retries": 3, + }, "outlook": { "imap_server": "outlook.office365.com", "imap_port": 993, diff --git a/src/config/settings.py b/src/config/settings.py index 20d5e956..f137153b 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -258,12 +258,18 @@ class SettingDefinition: # 邮箱服务配置 "email_service_priority": SettingDefinition( db_key="email.service_priority", - default_value={"tempmail": 0, "outlook": 1, "moe_mail": 2}, + default_value={"tempmail": 0, "yyds_mail": 1, "outlook": 2, "moe_mail": 3}, category=SettingCategory.EMAIL, description="邮箱服务优先级" ), # Tempmail.lol 配置 + "tempmail_enabled": SettingDefinition( + db_key="tempmail.enabled", + default_value=True, + category=SettingCategory.TEMPMAIL, + description="是否启用 Tempmail 渠道" + ), "tempmail_base_url": SettingDefinition( db_key="tempmail.base_url", default_value="https://api.tempmail.lol/v2", @@ -282,6 +288,43 @@ class SettingDefinition: category=SettingCategory.TEMPMAIL, description="Tempmail 最大重试次数" ), + "yyds_mail_enabled": SettingDefinition( + db_key="yyds_mail.enabled", + default_value=False, + category=SettingCategory.TEMPMAIL, + description="是否启用 YYDS Mail 渠道" + ), + "yyds_mail_base_url": SettingDefinition( + db_key="yyds_mail.base_url", + default_value="https://maliapi.215.im/v1", + category=SettingCategory.TEMPMAIL, + description="YYDS Mail API 地址" + ), + "yyds_mail_api_key": SettingDefinition( + db_key="yyds_mail.api_key", + default_value="", + category=SettingCategory.TEMPMAIL, + description="YYDS Mail API Key", + is_secret=True + ), + "yyds_mail_default_domain": SettingDefinition( + db_key="yyds_mail.default_domain", + default_value="", + category=SettingCategory.TEMPMAIL, + description="YYDS Mail 默认域名" + ), + "yyds_mail_timeout": SettingDefinition( + db_key="yyds_mail.timeout", + default_value=30, + category=SettingCategory.TEMPMAIL, + description="YYDS Mail 超时时间(秒)" + ), + "yyds_mail_max_retries": SettingDefinition( + db_key="yyds_mail.max_retries", + default_value=3, + category=SettingCategory.TEMPMAIL, + description="YYDS Mail 最大重试次数" + ), # 自定义域名邮箱配置 "custom_domain_base_url": SettingDefinition( @@ -408,8 +451,12 @@ class SettingDefinition: "registration_sleep_max": int, "registration_entry_flow": str, "email_service_priority": dict, + "tempmail_enabled": bool, "tempmail_timeout": int, "tempmail_max_retries": int, + "yyds_mail_enabled": bool, + "yyds_mail_timeout": int, + "yyds_mail_max_retries": int, "tm_enabled": bool, "cpa_enabled": bool, "email_code_timeout": int, @@ -673,12 +720,19 @@ def proxy_url(self) -> Optional[str]: registration_entry_flow: str = "native" # 邮箱服务配置 - email_service_priority: Dict[str, int] = {"tempmail": 0, "outlook": 1, "moe_mail": 2} + email_service_priority: Dict[str, int] = {"tempmail": 0, "yyds_mail": 1, "outlook": 2, "moe_mail": 3} # Tempmail.lol 配置 + tempmail_enabled: bool = True tempmail_base_url: str = "https://api.tempmail.lol/v2" tempmail_timeout: int = 30 tempmail_max_retries: int = 3 + yyds_mail_enabled: bool = False + yyds_mail_base_url: str = "https://maliapi.215.im/v1" + yyds_mail_api_key: Optional[SecretStr] = None + yyds_mail_default_domain: str = "" + yyds_mail_timeout: int = 30 + yyds_mail_max_retries: int = 3 # 自定义域名邮箱配置 custom_domain_base_url: str = "" diff --git a/src/services/__init__.py b/src/services/__init__.py index 575e0050..2e6a19f2 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -11,6 +11,7 @@ EmailServiceType ) from .tempmail import TempmailService +from .yyds_mail import YYDSMailService from .outlook import OutlookService from .moe_mail import MeoMailEmailService from .temp_mail import TempMailService @@ -21,6 +22,7 @@ # 注册服务 EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService) +EmailServiceFactory.register(EmailServiceType.YYDS_MAIL, YYDSMailService) EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService) EmailServiceFactory.register(EmailServiceType.MOE_MAIL, MeoMailEmailService) EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService) @@ -55,6 +57,7 @@ 'EmailServiceType', # 服务类 'TempmailService', + 'YYDSMailService', 'OutlookService', 'MeoMailEmailService', 'TempMailService', diff --git a/src/services/yyds_mail.py b/src/services/yyds_mail.py new file mode 100644 index 00000000..e79410ea --- /dev/null +++ b/src/services/yyds_mail.py @@ -0,0 +1,448 @@ +""" +YYDS Mail 邮箱服务实现 +基于 YYDS Mail REST API(/v1/accounts、/v1/token、/v1/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, OTP_CODE_SEMANTIC_PATTERN +from ..core.http_client import HTTPClient, RequestConfig + + +logger = logging.getLogger(__name__) + + +class YYDSMailService(BaseEmailService): + """YYDS Mail 临时邮箱服务""" + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + super().__init__(EmailServiceType.YYDS_MAIL, name) + + required_keys = ["base_url", "api_key"] + missing_keys = [key for key in required_keys if not (config or {}).get(key)] + if missing_keys: + raise ValueError(f"缺少必需配置: {missing_keys}") + + default_config = { + "default_domain": "", + "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.get("default_domain") or "").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]] = {} + self._last_used_message_ids: Dict[str, str] = {} + + 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", + } + + if token: + headers["Authorization"] = f"Bearer {token}" + elif use_api_key: + headers["X-API-Key"] = str(self.config["api_key"]).strip() + + if extra_headers: + headers.update(extra_headers) + + return headers + + def _unwrap_payload(self, payload: Any) -> Any: + if not isinstance(payload, dict): + return payload + + if payload.get("success") is False: + message = str(payload.get("error") or payload.get("message") or "请求失败").strip() + raise EmailServiceError(message) + + if "data" in payload: + return payload.get("data") + return payload + + def _make_request( + self, + method: str, + path: str, + *, + token: Optional[str] = None, + use_api_key: bool = False, + **kwargs, + ) -> 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 = str( + error_payload.get("error") + or error_payload.get("message") + or error_payload + ) + except Exception: + error_message = response.text[:200] or error_message + raise EmailServiceError(error_message) + + try: + payload = response.json() + except Exception: + payload = {} + + return self._unwrap_payload(payload) + 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: + prefix = "".join(random.choices(string.ascii_lowercase, k=7)) + suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=3)) + return f"{prefix}{suffix}" + + 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_cached_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).strip()) + if cached: + return cached + + if email: + cached = self._accounts_by_email.get(str(email).strip().lower()) + if cached: + return cached + + return None + + def _request_temp_token(self, email: str) -> Dict[str, Any]: + payload = self._make_request( + "POST", + "/token", + json={"address": str(email).strip()}, + ) + + resolved_email = str(payload.get("address") or email).strip() + account_id = str(payload.get("id") or "").strip() + token = str(payload.get("token") or "").strip() + if not resolved_email or not token: + raise EmailServiceError("YYDS Mail 返回的临时 Token 数据不完整") + + account_info = { + "email": resolved_email, + "service_id": account_id or resolved_email, + "id": account_id or resolved_email, + "account_id": account_id or resolved_email, + "token": token, + "created_at": time.time(), + } + self._cache_account(account_info) + return account_info + + def _parse_message_time(self, value: Any) -> Optional[float]: + if value is None: + return None + + if isinstance(value, (int, float)): + ts = float(value) + if ts > 10**12: + ts /= 1000.0 + return ts if ts > 0 else None + + text = str(value or "").strip() + if not text: + return None + + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + except Exception: + return None + + def _html_to_text(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 _sender_text(self, sender: Any) -> str: + if isinstance(sender, dict): + return " ".join( + str(sender.get(key) or "") for key in ("name", "address") + ).strip() + return str(sender or "").strip() + + def _message_search_text(self, summary: Dict[str, Any], detail: Dict[str, Any]) -> str: + sender_text = self._sender_text(detail.get("from") or summary.get("from")) + subject = str(detail.get("subject") or summary.get("subject") or "") + text_body = str(detail.get("text") or "") + html_body = self._html_to_text(detail.get("html")) + summary_blob = " ".join( + str(summary.get(key) or "") + for key in ("subject", "snippet", "preview") + ).strip() + return "\n".join( + part for part in [sender_text, subject, summary_blob, text_body, html_body] if part + ).strip() + + def _is_openai_otp_mail(self, content: str) -> bool: + text = str(content or "").lower() + if "openai" not in text: + return False + keywords = ( + "verification code", + "verify", + "one-time code", + "one time code", + "security code", + "your openai code", + "验证码", + "code is", + ) + return any(keyword in text for keyword in keywords) + + def _extract_otp_code(self, content: str, pattern: str) -> Optional[str]: + text = str(content or "") + if not text: + return None + + semantic_match = re.search(OTP_CODE_SEMANTIC_PATTERN, text, re.IGNORECASE) + if semantic_match: + return semantic_match.group(1) + + simple_match = re.search(pattern, text) + if simple_match: + return simple_match.group(1) + return None + + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + request_config = config or {} + local_part = str( + request_config.get("address") + or request_config.get("prefix") + or 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.get("default_domain") + or "" + ).strip().lstrip("@") + + payload: Dict[str, Any] = {"address": local_part} + if domain: + payload["domain"] = domain + + account_response = self._make_request( + "POST", + "/accounts", + json=payload, + use_api_key=True, + ) + + account_id = str(account_response.get("id") or "").strip() + email = str(account_response.get("address") or "").strip() + token = str(account_response.get("token") or "").strip() + + if not account_id or not email or not token: + raise EmailServiceError("YYDS Mail 返回数据不完整") + + email_info = { + "email": email, + "service_id": account_id, + "id": account_id, + "account_id": account_id, + "token": token, + "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_cached_account(email=email, email_id=email_id) + if not account_info: + try: + account_info = self._request_temp_token(email) + except Exception as e: + logger.warning(f"YYDS Mail 获取临时 Token 失败: {email} - {e}") + return None + + token = str(account_info.get("token") or "").strip() + if not token: + return None + + start_time = time.time() + seen_message_ids = set() + last_used_message_id = self._last_used_message_ids.get(str(email).strip().lower()) + + while time.time() - start_time < timeout: + try: + response = self._make_request( + "GET", + "/messages", + token=token, + params={ + "address": str(email).strip(), + "limit": 50, + }, + ) + messages = [] + if isinstance(response, dict): + messages = response.get("messages") or [] + elif isinstance(response, list): + messages = response + + for message in messages: + message_id = str(message.get("id") or "").strip() + if not message_id or message_id in seen_message_ids: + continue + if last_used_message_id and message_id == last_used_message_id: + 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 not self._is_openai_otp_mail(content): + continue + + code = self._extract_otp_code(content, pattern) + if code: + self._last_used_message_ids[str(email).strip().lower()] = message_id + self.update_status(True) + return code + except Exception as e: + logger.debug(f"YYDS Mail 轮询验证码失败: {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_cached_account(email_id=email_id) or self._get_cached_account(email=email_id) + if not account_info and email_id and "@" in str(email_id): + try: + account_info = self._request_temp_token(str(email_id)) + except Exception: + account_info = None + + if not account_info: + return False + + account_id = str(account_info.get("account_id") or account_info.get("service_id") or "").strip() + token = str(account_info.get("token") or "").strip() + if not account_id or not token: + return False + + try: + self._make_request( + "DELETE", + f"/accounts/{account_id}", + token=token, + ) + self._accounts_by_id.pop(account_id, None) + self._accounts_by_email.pop(str(account_info.get("email") or "").strip().lower(), None) + self.update_status(True) + return True + except Exception as e: + logger.warning(f"YYDS Mail 删除邮箱失败: {e}") + self.update_status(False, e) + return False + + def check_health(self) -> bool: + try: + self._make_request( + "GET", + "/me", + use_api_key=True, + ) + self.update_status(True) + return True + except Exception as e: + logger.warning(f"YYDS Mail 健康检查失败: {e}") + self.update_status(False, e) + return False + + 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.get("default_domain") or "", + "cached_accounts": len(self._accounts_by_email), + "status": self.status.value, + } diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 631b063c..4024d90e 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -2240,6 +2240,16 @@ def _build_inbox_config(db, service_type, email: str) -> dict: "max_retries": settings.tempmail_max_retries, } + if service_type == EST.YYDS_MAIL: + settings = get_settings() + return { + "base_url": settings.yyds_mail_base_url, + "api_key": settings.yyds_mail_api_key.get_secret_value() if settings.yyds_mail_api_key else "", + "default_domain": settings.yyds_mail_default_domain, + "timeout": settings.yyds_mail_timeout, + "max_retries": settings.yyds_mail_max_retries, + } + if service_type == EST.MOE_MAIL: # 按域名后缀匹配,找不到则取 priority 最小的 domain = email.split("@")[1] if "@" in email else "" diff --git a/src/web/routes/email.py b/src/web/routes/email.py index 305efeff..30d23a05 100644 --- a/src/web/routes/email.py +++ b/src/web/routes/email.py @@ -13,6 +13,7 @@ from ...database.session import get_db from ...database.models import EmailService as EmailServiceModel from ...database.models import Account as AccountModel +from ...config.settings import get_settings from ...services import EmailServiceFactory, EmailServiceType logger = logging.getLogger(__name__) @@ -159,8 +160,6 @@ def service_to_response(service: EmailServiceModel) -> EmailServiceResponse: async def get_email_services_stats(): """获取邮箱服务统计信息""" with get_db() as db: - from sqlalchemy import func - # 按类型统计 type_stats = db.query( EmailServiceModel.service_type, @@ -172,6 +171,14 @@ async def get_email_services_stats(): EmailServiceModel.enabled == True ).scalar() + settings = get_settings() + tempmail_enabled = bool(settings.tempmail_enabled) + yyds_enabled = bool( + settings.yyds_mail_enabled + and settings.yyds_mail_api_key + and settings.yyds_mail_api_key.get_secret_value() + ) + stats = { 'outlook_count': 0, 'custom_count': 0, @@ -180,7 +187,8 @@ async def get_email_services_stats(): 'freemail_count': 0, 'imap_mail_count': 0, 'cloudmail_count': 0, - 'tempmail_available': True, # 临时邮箱始终可用 + 'tempmail_available': tempmail_enabled or yyds_enabled, + 'yyds_mail_available': yyds_enabled, 'enabled_count': enabled_count } @@ -211,12 +219,23 @@ async def get_service_types(): { "value": "tempmail", "label": "Tempmail.lol", - "description": "临时邮箱服务,无需配置", + "description": "官方内置临时邮箱渠道,通过全局配置使用", "config_fields": [ {"name": "base_url", "label": "API 地址", "default": "https://api.tempmail.lol/v2", "required": False}, {"name": "timeout", "label": "超时时间", "default": 30, "required": False}, ] }, + { + "value": "yyds_mail", + "label": "YYDS Mail", + "description": "官方内置临时邮箱渠道,使用 X-API-Key 创建邮箱并轮询消息", + "config_fields": [ + {"name": "base_url", "label": "API 地址", "default": "https://maliapi.215.im/v1", "required": False}, + {"name": "api_key", "label": "API Key", "required": True, "secret": True}, + {"name": "default_domain", "label": "默认域名", "required": False, "placeholder": "public.example.com"}, + {"name": "timeout", "label": "超时时间", "default": 30, "required": False}, + ] + }, { "value": "outlook", "label": "Outlook", @@ -617,29 +636,52 @@ async def batch_delete_outlook(service_ids: List[int]): class TempmailTestRequest(BaseModel): """临时邮箱测试请求""" + provider: str = "tempmail" api_url: Optional[str] = None + api_key: Optional[str] = None @router.post("/test-tempmail") async def test_tempmail_service(request: TempmailTestRequest): """测试临时邮箱服务是否可用""" try: - from ...services import EmailServiceFactory, EmailServiceType - from ...config.settings import get_settings - settings = get_settings() - base_url = request.api_url or settings.tempmail_base_url + provider = str(request.provider or "tempmail").strip().lower() - config = {"base_url": base_url} - tempmail = EmailServiceFactory.create(EmailServiceType.TEMPMAIL, config) + if provider == "yyds_mail": + base_url = request.api_url or settings.yyds_mail_base_url + api_key = request.api_key + if api_key is None and settings.yyds_mail_api_key: + api_key = settings.yyds_mail_api_key.get_secret_value() + + config = { + "base_url": base_url, + "api_key": api_key or "", + "default_domain": settings.yyds_mail_default_domain, + "timeout": settings.yyds_mail_timeout, + "max_retries": settings.yyds_mail_max_retries, + } + service = EmailServiceFactory.create(EmailServiceType.YYDS_MAIL, config) + success_message = "YYDS Mail 连接正常" + fail_message = "YYDS Mail 连接失败" + else: + base_url = request.api_url or settings.tempmail_base_url + config = { + "base_url": base_url, + "timeout": settings.tempmail_timeout, + "max_retries": settings.tempmail_max_retries, + } + service = EmailServiceFactory.create(EmailServiceType.TEMPMAIL, config) + success_message = "临时邮箱连接正常" + fail_message = "临时邮箱连接失败" # 检查服务健康状态 - health = tempmail.check_health() + health = service.check_health() if health: - return {"success": True, "message": "临时邮箱连接正常"} + return {"success": True, "message": success_message} else: - return {"success": False, "message": "临时邮箱连接失败"} + return {"success": False, "message": fail_message} except Exception as e: logger.error(f"测试临时邮箱失败: {e}") diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py index ee27ce57..17c30729 100644 --- a/src/web/routes/payment.py +++ b/src/web/routes/payment.py @@ -737,6 +737,9 @@ def _normalize_email_service_config_for_session_bootstrap( if service_type == EmailServiceType.MOE_MAIL: if "domain" in normalized and "default_domain" not in normalized: normalized["default_domain"] = normalized.pop("domain") + elif service_type == EmailServiceType.YYDS_MAIL: + if "domain" in normalized and "default_domain" not in normalized: + normalized["default_domain"] = normalized.pop("domain") elif service_type in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL): if "default_domain" in normalized and "domain" not in normalized: normalized["domain"] = normalized.pop("default_domain") @@ -790,6 +793,18 @@ def _resolve_email_service_for_account_session_bootstrap(db, account: Account, p "max_retries": settings.tempmail_max_retries, "proxy_url": proxy, } + elif service_type == EmailServiceType.YYDS_MAIL: + api_key = settings.yyds_mail_api_key.get_secret_value() if settings.yyds_mail_api_key else "" + if not settings.yyds_mail_enabled or not api_key: + raise RuntimeError("YYDS Mail 渠道未启用或未配置 API Key,无法自动获取登录验证码") + config = { + "base_url": settings.yyds_mail_base_url, + "api_key": api_key, + "default_domain": settings.yyds_mail_default_domain, + "timeout": settings.yyds_mail_timeout, + "max_retries": settings.yyds_mail_max_retries, + "proxy_url": proxy, + } else: raise RuntimeError( f"未找到可用邮箱服务配置(type={service_type.value}),无法自动获取登录验证码" diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index d428262e..43449ffd 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -208,6 +208,9 @@ def _normalize_email_service_config( if service_type == EmailServiceType.MOE_MAIL: if 'domain' in normalized and 'default_domain' not in normalized: normalized['default_domain'] = normalized.pop('domain') + elif service_type == EmailServiceType.YYDS_MAIL: + if 'domain' in normalized and 'default_domain' not in normalized: + normalized['default_domain'] = normalized.pop('domain') elif service_type in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL): if 'default_domain' in normalized and 'domain' not in normalized: normalized['domain'] = normalized.pop('default_domain') @@ -285,12 +288,26 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: else: # 使用默认配置或传入的配置 if service_type == EmailServiceType.TEMPMAIL: + if not settings.tempmail_enabled: + raise ValueError("Tempmail.lol 渠道已禁用,请先在邮箱服务页面启用") config = { "base_url": settings.tempmail_base_url, "timeout": settings.tempmail_timeout, "max_retries": settings.tempmail_max_retries, "proxy_url": actual_proxy_url, } + elif service_type == EmailServiceType.YYDS_MAIL: + api_key = settings.yyds_mail_api_key.get_secret_value() if settings.yyds_mail_api_key else "" + if not settings.yyds_mail_enabled or not api_key: + raise ValueError("YYDS Mail 渠道未启用或未配置 API Key,请先在邮箱服务页面配置") + config = { + "base_url": settings.yyds_mail_base_url, + "api_key": api_key, + "default_domain": settings.yyds_mail_default_domain, + "timeout": settings.yyds_mail_timeout, + "max_retries": settings.yyds_mail_max_retries, + "proxy_url": actual_proxy_url, + } elif service_type == EmailServiceType.MOE_MAIL: # 检查数据库中是否有可用的自定义域名服务 from ...database.models import EmailService as EmailServiceModel @@ -1105,6 +1122,7 @@ async def get_available_email_services(): 返回所有已启用的邮箱服务,包括: - tempmail: 临时邮箱(无需配置) + - yyds_mail: YYDS Mail 临时邮箱(需 API Key) - outlook: 已导入的 Outlook 账户 - moe_mail: 已配置的自定义域名服务 """ @@ -1114,14 +1132,19 @@ async def get_available_email_services(): settings = get_settings() result = { "tempmail": { - "available": True, - "count": 1, - "services": [{ + "available": bool(settings.tempmail_enabled), + "count": 1 if settings.tempmail_enabled else 0, + "services": ([{ "id": None, "name": "Tempmail.lol", "type": "tempmail", "description": "临时邮箱,自动创建" - }] + }] if settings.tempmail_enabled else []) + }, + "yyds_mail": { + "available": False, + "count": 0, + "services": [] }, "outlook": { "available": False, @@ -1155,6 +1178,18 @@ async def get_available_email_services(): } } + yyds_api_key = settings.yyds_mail_api_key.get_secret_value() if settings.yyds_mail_api_key else "" + if settings.yyds_mail_enabled and yyds_api_key: + result["yyds_mail"]["available"] = True + result["yyds_mail"]["count"] = 1 + result["yyds_mail"]["services"].append({ + "id": None, + "name": "YYDS Mail", + "type": "yyds_mail", + "default_domain": settings.yyds_mail_default_domain or None, + "description": "YYDS Mail API 临时邮箱", + }) + with get_db() as db: # 获取 Outlook 账户 outlook_services = db.query(EmailServiceModel).filter( diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index 692e7f1e..a41799c5 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -106,10 +106,21 @@ async def get_all_settings(): "has_access_password": bool(settings.webui_access_password and settings.webui_access_password.get_secret_value()), }, "tempmail": { + "enabled": settings.tempmail_enabled, + "api_url": settings.tempmail_base_url, "base_url": settings.tempmail_base_url, "timeout": settings.tempmail_timeout, "max_retries": settings.tempmail_max_retries, }, + "yyds_mail": { + "enabled": settings.yyds_mail_enabled, + "api_url": settings.yyds_mail_base_url, + "base_url": settings.yyds_mail_base_url, + "default_domain": settings.yyds_mail_default_domain, + "timeout": settings.yyds_mail_timeout, + "max_retries": settings.yyds_mail_max_retries, + "has_api_key": bool(settings.yyds_mail_api_key and settings.yyds_mail_api_key.get_secret_value()), + }, "email_code": { "timeout": settings.email_code_timeout, "poll_interval": settings.email_code_poll_interval, @@ -486,7 +497,11 @@ async def get_recent_logs( class TempmailSettings(BaseModel): """临时邮箱设置""" api_url: Optional[str] = None - enabled: bool = True + enabled: Optional[bool] = None + yyds_api_url: Optional[str] = None + yyds_api_key: Optional[str] = None + yyds_default_domain: Optional[str] = None + yyds_enabled: Optional[bool] = None class EmailCodeSettings(BaseModel): @@ -501,10 +516,20 @@ async def get_tempmail_settings(): settings = get_settings() return { - "api_url": settings.tempmail_base_url, - "timeout": settings.tempmail_timeout, - "max_retries": settings.tempmail_max_retries, - "enabled": True # 临时邮箱默认可用 + "tempmail": { + "api_url": settings.tempmail_base_url, + "timeout": settings.tempmail_timeout, + "max_retries": settings.tempmail_max_retries, + "enabled": settings.tempmail_enabled, + }, + "yyds_mail": { + "api_url": settings.yyds_mail_base_url, + "default_domain": settings.yyds_mail_default_domain, + "timeout": settings.yyds_mail_timeout, + "max_retries": settings.yyds_mail_max_retries, + "enabled": settings.yyds_mail_enabled, + "has_api_key": bool(settings.yyds_mail_api_key and settings.yyds_mail_api_key.get_secret_value()), + }, } @@ -515,6 +540,16 @@ async def update_tempmail_settings(request: TempmailSettings): if request.api_url: update_dict["tempmail_base_url"] = request.api_url + if request.enabled is not None: + update_dict["tempmail_enabled"] = request.enabled + if request.yyds_api_url is not None: + update_dict["yyds_mail_base_url"] = request.yyds_api_url + if request.yyds_api_key is not None: + update_dict["yyds_mail_api_key"] = request.yyds_api_key + if request.yyds_default_domain is not None: + update_dict["yyds_mail_default_domain"] = request.yyds_default_domain + if request.yyds_enabled is not None: + update_dict["yyds_mail_enabled"] = request.yyds_enabled update_settings(**update_dict) diff --git a/static/js/app.js b/static/js/app.js index d96295b5..80118e2d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -22,6 +22,7 @@ let displayedLogs = new Set(); // 用于日志去重 let toastShown = false; // 标记是否已显示过 toast let availableServices = { tempmail: { available: true, services: [] }, + yyds_mail: { available: false, services: [] }, outlook: { available: false, services: [] }, moe_mail: { available: false, services: [] }, temp_mail: { available: false, services: [] }, @@ -252,18 +253,31 @@ function updateEmailServiceOptions() { const select = elements.emailService; select.innerHTML = ''; - // Tempmail - if (availableServices.tempmail.available) { + // 官方临时邮箱渠道 + if ((availableServices.tempmail && availableServices.tempmail.available) || + (availableServices.yyds_mail && availableServices.yyds_mail.available)) { const optgroup = document.createElement('optgroup'); optgroup.label = '🌐 临时邮箱'; - availableServices.tempmail.services.forEach(service => { - const option = document.createElement('option'); - option.value = `tempmail:${service.id || 'default'}`; - option.textContent = service.name; - option.dataset.type = 'tempmail'; - optgroup.appendChild(option); - }); + if (availableServices.tempmail && availableServices.tempmail.available) { + availableServices.tempmail.services.forEach(service => { + const option = document.createElement('option'); + option.value = `tempmail:${service.id || 'default'}`; + option.textContent = service.name; + option.dataset.type = 'tempmail'; + optgroup.appendChild(option); + }); + } + + if (availableServices.yyds_mail && availableServices.yyds_mail.available) { + availableServices.yyds_mail.services.forEach(service => { + const option = document.createElement('option'); + option.value = `yyds_mail:${service.id || 'default'}`; + option.textContent = service.name + (service.default_domain ? ` (@${service.default_domain})` : ''); + option.dataset.type = 'yyds_mail'; + optgroup.appendChild(option); + }); + } select.appendChild(optgroup); } @@ -413,6 +427,11 @@ function handleServiceChange(e) { if (service) { addLog('info', `[系统] 已选择 Outlook 账户: ${service.name}`); } + } else if (type === 'yyds_mail') { + const service = availableServices.yyds_mail.services.find(s => (s.id || 'default') == id); + if (service) { + addLog('info', `[系统] 已选择 YYDS Mail 渠道: ${service.name}`); + } } else if (type === 'moe_mail') { const service = availableServices.moe_mail.services.find(s => s.id == id); if (service) { diff --git a/static/js/email_services.js b/static/js/email_services.js index 9583b2b5..401cc7e7 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -41,6 +41,12 @@ const elements = { tempmailApi: document.getElementById('tempmail-api'), tempmailEnabled: document.getElementById('tempmail-enabled'), testTempmailBtn: document.getElementById('test-tempmail-btn'), + yydsMailForm: document.getElementById('yyds-mail-form'), + yydsMailApi: document.getElementById('yyds-mail-api'), + yydsMailApiKey: document.getElementById('yyds-mail-api-key'), + yydsMailDomain: document.getElementById('yyds-mail-domain'), + yydsMailEnabled: document.getElementById('yyds-mail-enabled'), + testYydsMailBtn: document.getElementById('test-yyds-mail-btn'), // 添加自定义域名模态框 addCustomModal: document.getElementById('add-custom-modal'), @@ -159,6 +165,8 @@ function initEventListeners() { // 临时邮箱配置 elements.tempmailForm.addEventListener('submit', handleSaveTempmail); elements.testTempmailBtn.addEventListener('click', handleTestTempmail); + elements.yydsMailForm.addEventListener('submit', handleSaveYydsMail); + elements.testYydsMailBtn.addEventListener('click', handleTestYydsMail); // 点击其他地方关闭更多菜单 document.addEventListener('click', () => { @@ -401,9 +409,17 @@ async function loadTempmailConfig() { try { const settings = await api.get('/settings'); if (settings.tempmail) { - elements.tempmailApi.value = settings.tempmail.api_url || ''; + elements.tempmailApi.value = settings.tempmail.api_url || settings.tempmail.base_url || ''; elements.tempmailEnabled.checked = settings.tempmail.enabled !== false; } + if (settings.yyds_mail) { + elements.yydsMailApi.value = settings.yyds_mail.api_url || settings.yyds_mail.base_url || ''; + elements.yydsMailDomain.value = settings.yyds_mail.default_domain || ''; + elements.yydsMailEnabled.checked = settings.yyds_mail.enabled === true; + elements.yydsMailApiKey.value = ''; + elements.yydsMailApiKey.dataset.hasKey = settings.yyds_mail.has_api_key ? 'true' : 'false'; + elements.yydsMailApiKey.placeholder = settings.yyds_mail.has_api_key ? '已设置,留空保持不变' : 'AC-your_api_key'; + } } catch (error) { // 忽略错误 } @@ -584,6 +600,7 @@ async function handleSaveTempmail(e) { enabled: elements.tempmailEnabled.checked }); toast.success('配置已保存'); + loadStats(); } catch (error) { toast.error('保存失败: ' + error.message); } @@ -595,6 +612,7 @@ async function handleTestTempmail() { elements.testTempmailBtn.textContent = '测试中...'; try { const result = await api.post('/email-services/test-tempmail', { + provider: 'tempmail', api_url: elements.tempmailApi.value }); if (result.success) toast.success('临时邮箱连接正常'); @@ -607,6 +625,65 @@ async function handleTestTempmail() { } } +// 保存 YYDS Mail 配置 +async function handleSaveYydsMail(e) { + e.preventDefault(); + const apiKey = elements.yydsMailApiKey.value.trim(); + const hasSavedKey = elements.yydsMailApiKey.dataset.hasKey === 'true'; + + if (elements.yydsMailEnabled.checked && !apiKey && !hasSavedKey) { + toast.error('启用 YYDS Mail 前请先填写 API Key'); + return; + } + + const payload = { + yyds_api_url: elements.yydsMailApi.value, + yyds_default_domain: elements.yydsMailDomain.value, + yyds_enabled: elements.yydsMailEnabled.checked + }; + if (apiKey || !hasSavedKey) { + payload.yyds_api_key = apiKey; + } + + try { + await api.post('/settings/tempmail', payload); + if (apiKey) { + elements.yydsMailApiKey.value = ''; + elements.yydsMailApiKey.dataset.hasKey = 'true'; + elements.yydsMailApiKey.placeholder = '已设置,留空保持不变'; + } else if (!hasSavedKey && !apiKey) { + elements.yydsMailApiKey.dataset.hasKey = 'false'; + } + toast.success('YYDS Mail 配置已保存'); + loadStats(); + } catch (error) { + toast.error('保存失败: ' + error.message); + } +} + +// 测试 YYDS Mail +async function handleTestYydsMail() { + elements.testYydsMailBtn.disabled = true; + elements.testYydsMailBtn.textContent = '测试中...'; + try { + const payload = { + provider: 'yyds_mail', + api_url: elements.yydsMailApi.value + }; + const apiKey = elements.yydsMailApiKey.value.trim(); + if (apiKey) payload.api_key = apiKey; + + const result = await api.post('/email-services/test-tempmail', payload); + if (result.success) toast.success('YYDS Mail 连接正常'); + else toast.error('连接失败: ' + (result.error || '未知错误')); + } catch (error) { + toast.error('测试失败: ' + error.message); + } finally { + elements.testYydsMailBtn.disabled = false; + elements.testYydsMailBtn.textContent = '🔌 测试连接'; + } +} + // 更新批量按钮 function updateBatchButtons() { const count = selectedOutlook.size; diff --git a/static/js/utils.js b/static/js/utils.js index b7b5dab0..570ce244 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -350,6 +350,7 @@ const statusMap = { }, service: { tempmail: 'Tempmail.lol', + yyds_mail: 'YYDS Mail', outlook: 'Outlook', moe_mail: 'MoeMail', temp_mail: 'Temp-Mail(自部署)', diff --git a/templates/accounts.html b/templates/accounts.html index fa28f782..f16f7c6d 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -171,6 +171,7 @@

账号管理

diff --git a/templates/email_services.html b/templates/email_services.html index ad2480ef..179a3a7f 100644 --- a/templates/email_services.html +++ b/templates/email_services.html @@ -177,22 +177,60 @@

📧 Outlook 账户列表

🌐 临时邮箱配置

-
-
- - -
-
- -
-
- - -
-
+
+
+
+

Tempmail.lol

+
+
+
+ + +
+
+ +
+
+ + +
+
+
+ +
+
+

YYDS Mail

+
+
+
+ + +
+
+ + + 使用文档中的 `X-API-Key` 调用方式 +
+
+ + +
+
+ +
+
+ + +
+
+
+
diff --git a/templates/index.html b/templates/index.html index 1c940ab4..0cf09508 100644 --- a/templates/index.html +++ b/templates/index.html @@ -306,6 +306,7 @@

📝 注册设置

+ @@ -274,7 +275,21 @@

➕ 添加自定义邮箱服务

- + + - + +