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/tempmail.py b/src/services/tempmail.py index e5d0f2b9..ecdf6e84 100644 --- a/src/services/tempmail.py +++ b/src/services/tempmail.py @@ -7,6 +7,7 @@ import logging from typing import Optional, Dict, Any, List import json +import os from curl_cffi import requests as cffi_requests @@ -48,6 +49,12 @@ def __init__(self, config: Dict[str, Any] = None, name: str = None): self.config = {**default_config, **(config or {})} + # ←←← 最终优化:API Key 完全可选(付费/免费自动切换)↓↓↓ + self.api_key = self.config.get("api_key") or os.getenv("TEMPMail_API_KEY") + if self.api_key: + logger.info(f"✅ Tempmail.lol Plus/Ultra 已加载 API Key: {self.api_key[:30]}...") + # 没 Key 时不打印任何警告,直接走免费模式(符合你的需求) + # 创建 HTTP 客户端 http_config = RequestConfig( timeout=self.config["timeout"], @@ -83,6 +90,7 @@ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: headers={ "Accept": "application/json", "Content-Type": "application/json", + **({"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}), }, json={} ) @@ -163,7 +171,10 @@ def get_verification_code( response = self.http_client.get( f"{self.config['base_url']}/inbox", params={"token": token}, - headers={"Accept": "application/json"} + headers={ + "Accept": "application/json", + **({"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}), + } ) if response.status_code != 200: 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..8dd4ef1f 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,15 +171,25 @@ 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, + 'yyds_mail_count': 0, 'temp_mail_count': 0, 'duck_mail_count': 0, '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 } @@ -189,6 +198,8 @@ async def get_email_services_stats(): stats['outlook_count'] = count elif service_type == 'moe_mail': stats['custom_count'] = count + elif service_type == 'yyds_mail': + stats['yyds_mail_count'] = count elif service_type == 'temp_mail': stats['temp_mail_count'] = count elif service_type == 'duck_mail': @@ -211,12 +222,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 +639,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..cb89f056 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,7 +1178,37 @@ 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: + yyds_mail_services = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "yyds_mail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).all() + + for service in yyds_mail_services: + config = service.config or {} + result["yyds_mail"]["services"].append({ + "id": service.id, + "name": service.name, + "type": "yyds_mail", + "default_domain": config.get("default_domain"), + "priority": service.priority + }) + + if yyds_mail_services: + result["yyds_mail"]["count"] = len(result["yyds_mail"]["services"]) + result["yyds_mail"]["available"] = True # 获取 Outlook 账户 outlook_services = db.query(EmailServiceModel).filter( EmailServiceModel.service_type == "outlook", 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..8fd65b6d 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: [] }, @@ -37,6 +38,15 @@ let wsHeartbeatInterval = null; // 心跳定时器 let batchWsHeartbeatInterval = null; // 批量任务心跳定时器 let activeTaskUuid = null; // 当前活跃的单任务 UUID(用于页面重新可见时重连) let activeBatchId = null; // 当前活跃的批量任务 ID(用于页面重新可见时重连) +let wsReconnectTimer = null; +let batchWsReconnectTimer = null; +let wsReconnectAttempts = 0; +let batchWsReconnectAttempts = 0; +let wsManualClose = false; +let batchWsManualClose = false; + +const WS_RECONNECT_BASE_DELAY = 1000; +const WS_RECONNECT_MAX_DELAY = 10000; // DOM 元素 const elements = { @@ -252,18 +262,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 +436,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) { @@ -539,24 +567,105 @@ async function handleSingleRegistration(requestData) { // ============== WebSocket 功能 ============== +function getReconnectDelay(attempt) { + return Math.min(WS_RECONNECT_BASE_DELAY * (2 ** Math.max(0, attempt - 1)), WS_RECONNECT_MAX_DELAY); +} + +function clearWebSocketReconnect() { + if (wsReconnectTimer) { + clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + } + wsReconnectAttempts = 0; +} + +function clearBatchWebSocketReconnect() { + if (batchWsReconnectTimer) { + clearTimeout(batchWsReconnectTimer); + batchWsReconnectTimer = null; + } + batchWsReconnectAttempts = 0; +} + +function scheduleWebSocketReconnect(taskUuid) { + if (!taskUuid || wsReconnectTimer || wsManualClose || taskCompleted || taskFinalStatus !== null || activeTaskUuid !== taskUuid) { + return; + } + + wsReconnectAttempts += 1; + const delay = getReconnectDelay(wsReconnectAttempts); + addLog('warning', `[系统] WebSocket 已断开,${delay / 1000} 秒后尝试重连任务监控...`); + + wsReconnectTimer = setTimeout(() => { + wsReconnectTimer = null; + connectWebSocket(taskUuid); + }, delay); +} + +function scheduleBatchWebSocketReconnect(batchId) { + if (!batchId || batchWsReconnectTimer || batchWsManualClose || batchCompleted || batchFinalStatus !== null || activeBatchId !== batchId) { + return; + } + + batchWsReconnectAttempts += 1; + const delay = getReconnectDelay(batchWsReconnectAttempts); + addLog('warning', `[系统] 批量任务 WebSocket 已断开,${delay / 1000} 秒后尝试重连监控...`); + + batchWsReconnectTimer = setTimeout(() => { + batchWsReconnectTimer = null; + connectBatchWebSocket(batchId); + }, delay); +} + +function startCurrentBatchPolling(batchId) { + if (!batchId) return; + + const pollingMode = currentBatch && currentBatch.batch_id === batchId + ? currentBatch.pollingMode + : (isOutlookBatchMode ? 'outlook_batch' : 'batch'); + + if (pollingMode === 'outlook_batch') { + startOutlookBatchPolling(batchId); + return; + } + + startBatchPolling(batchId); +} + // 连接 WebSocket function connectWebSocket(taskUuid) { + activeTaskUuid = taskUuid; + + if (webSocket && [WebSocket.OPEN, WebSocket.CONNECTING].includes(webSocket.readyState)) { + return; + } + + if (wsReconnectTimer) { + clearTimeout(wsReconnectTimer); + wsReconnectTimer = null; + } + wsManualClose = false; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/api/ws/task/${taskUuid}`; try { - webSocket = new WebSocket(wsUrl); + const socket = new WebSocket(wsUrl); + webSocket = socket; - webSocket.onopen = () => { + socket.onopen = () => { + if (webSocket !== socket) return; console.log('WebSocket 连接成功'); useWebSocket = true; + clearWebSocketReconnect(); // 停止轮询(如果有) stopLogPolling(); // 开始心跳 startWebSocketHeartbeat(); }; - webSocket.onmessage = (event) => { + socket.onmessage = (event) => { + if (webSocket !== socket) return; const data = JSON.parse(event.data); if (data.type === 'log') { @@ -604,43 +713,52 @@ function connectWebSocket(taskUuid) { } }; - webSocket.onclose = (event) => { + socket.onclose = (event) => { + const isCurrentSocket = webSocket === socket; + if (isCurrentSocket) { + webSocket = null; + stopWebSocketHeartbeat(); + } + console.log('WebSocket 连接关闭:', event.code); - stopWebSocketHeartbeat(); - // 只有在任务未完成且最终状态不是完成状态时才切换到轮询 - // 使用 taskFinalStatus 而不是 currentTask.status,因为 currentTask 可能已被重置 - const shouldPoll = !taskCompleted && - taskFinalStatus === null; // 如果 taskFinalStatus 有值,说明任务已完成 + const shouldReconnect = isCurrentSocket && + !wsManualClose && + !taskCompleted && + taskFinalStatus === null && + activeTaskUuid === taskUuid; - if (shouldPoll && currentTask) { - console.log('切换到轮询模式'); + if (shouldReconnect) { + console.log('WebSocket 断开,准备自动重连'); useWebSocket = false; - startLogPolling(currentTask.task_uuid); + startLogPolling(taskUuid); + scheduleWebSocketReconnect(taskUuid); } }; - webSocket.onerror = (error) => { + socket.onerror = (error) => { + if (webSocket !== socket) return; console.error('WebSocket 错误:', error); - // 切换到轮询 useWebSocket = false; - stopWebSocketHeartbeat(); - startLogPolling(taskUuid); }; } catch (error) { console.error('WebSocket 连接失败:', error); useWebSocket = false; startLogPolling(taskUuid); + scheduleWebSocketReconnect(taskUuid); } } // 断开 WebSocket function disconnectWebSocket() { + wsManualClose = true; + clearWebSocketReconnect(); stopWebSocketHeartbeat(); if (webSocket) { - webSocket.close(); + const socket = webSocket; webSocket = null; + socket.close(); } } @@ -694,7 +812,7 @@ async function handleBatchRegistration(requestData) { try { const data = await api.post('/registration/batch', requestData); - currentBatch = data; + currentBatch = { ...data, pollingMode: 'batch' }; activeBatchId = data.batch_id; // 保存用于重连 // 持久化到 sessionStorage,跨页面导航后可恢复 sessionStorage.setItem('activeTask', JSON.stringify({ batch_id: data.batch_id, mode: 'batch', total: data.count })); @@ -771,6 +889,10 @@ async function handleCancelTask() { // 开始轮询日志 function startLogPolling(taskUuid) { + if (logPollingInterval) { + return; + } + let lastLogIndex = 0; logPollingInterval = setInterval(async () => { @@ -834,6 +956,10 @@ function stopLogPolling() { // 开始轮询批量状态 function startBatchPolling(batchId) { + if (batchPollingInterval) { + return; + } + batchPollingInterval = setInterval(async () => { try { const data = await api.get(`/registration/batch/${batchId}`); @@ -1141,6 +1267,10 @@ function getLogType(log) { function resetButtons() { elements.startBtn.disabled = false; elements.cancelBtn.disabled = true; + stopLogPolling(); + stopBatchPolling(); + clearWebSocketReconnect(); + clearBatchWebSocketReconnect(); currentTask = null; currentBatch = null; isBatchMode = false; @@ -1298,7 +1428,7 @@ async function handleOutlookBatchRegistration() { return; } - currentBatch = { batch_id: data.batch_id, ...data }; + currentBatch = { batch_id: data.batch_id, ...data, pollingMode: 'outlook_batch' }; activeBatchId = data.batch_id; // 保存用于重连 // 持久化到 sessionStorage,跨页面导航后可恢复 sessionStorage.setItem('activeTask', JSON.stringify({ batch_id: data.batch_id, mode: isOutlookBatchMode ? 'outlook_batch' : 'batch', total: data.to_register })); @@ -1322,21 +1452,37 @@ async function handleOutlookBatchRegistration() { // 连接批量任务 WebSocket function connectBatchWebSocket(batchId) { + activeBatchId = batchId; + + if (batchWebSocket && [WebSocket.OPEN, WebSocket.CONNECTING].includes(batchWebSocket.readyState)) { + return; + } + + if (batchWsReconnectTimer) { + clearTimeout(batchWsReconnectTimer); + batchWsReconnectTimer = null; + } + batchWsManualClose = false; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/api/ws/batch/${batchId}`; try { - batchWebSocket = new WebSocket(wsUrl); + const socket = new WebSocket(wsUrl); + batchWebSocket = socket; - batchWebSocket.onopen = () => { + socket.onopen = () => { + if (batchWebSocket !== socket) return; console.log('批量任务 WebSocket 连接成功'); + clearBatchWebSocketReconnect(); // 停止轮询(如果有) stopBatchPolling(); // 开始心跳 startBatchWebSocketHeartbeat(); }; - batchWebSocket.onmessage = (event) => { + socket.onmessage = (event) => { + if (batchWebSocket !== socket) return; const data = JSON.parse(event.data); if (data.type === 'log') { @@ -1389,40 +1535,49 @@ function connectBatchWebSocket(batchId) { } }; - batchWebSocket.onclose = (event) => { + socket.onclose = (event) => { + const isCurrentSocket = batchWebSocket === socket; + if (isCurrentSocket) { + batchWebSocket = null; + stopBatchWebSocketHeartbeat(); + } + console.log('批量任务 WebSocket 连接关闭:', event.code); - stopBatchWebSocketHeartbeat(); - // 只有在任务未完成且最终状态不是完成状态时才切换到轮询 - // 使用 batchFinalStatus 而不是 currentBatch.status,因为 currentBatch 可能已被重置 - const shouldPoll = !batchCompleted && - batchFinalStatus === null; // 如果 batchFinalStatus 有值,说明任务已完成 + const shouldReconnect = isCurrentSocket && + !batchWsManualClose && + !batchCompleted && + batchFinalStatus === null && + activeBatchId === batchId; - if (shouldPoll && currentBatch) { - console.log('切换到轮询模式'); - startOutlookBatchPolling(currentBatch.batch_id); + if (shouldReconnect) { + console.log('批量任务 WebSocket 断开,准备自动重连'); + startCurrentBatchPolling(batchId); + scheduleBatchWebSocketReconnect(batchId); } }; - batchWebSocket.onerror = (error) => { + socket.onerror = (error) => { + if (batchWebSocket !== socket) return; console.error('批量任务 WebSocket 错误:', error); - stopBatchWebSocketHeartbeat(); - // 切换到轮询 - startOutlookBatchPolling(batchId); }; } catch (error) { console.error('批量任务 WebSocket 连接失败:', error); - startOutlookBatchPolling(batchId); + startCurrentBatchPolling(batchId); + scheduleBatchWebSocketReconnect(batchId); } } // 断开批量任务 WebSocket function disconnectBatchWebSocket() { + batchWsManualClose = true; + clearBatchWebSocketReconnect(); stopBatchWebSocketHeartbeat(); if (batchWebSocket) { - batchWebSocket.close(); + const socket = batchWebSocket; batchWebSocket = null; + socket.close(); } } @@ -1453,6 +1608,10 @@ function cancelBatchViaWebSocket() { // 开始轮询 Outlook 批量状态(降级方案) function startOutlookBatchPolling(batchId) { + if (batchPollingInterval) { + return; + } + batchPollingInterval = setInterval(async () => { try { const data = await api.get(`/registration/outlook-batch/${batchId}`); @@ -1578,7 +1737,7 @@ async function restoreActiveTask() { return; } // 批量任务仍在运行,恢复状态 - currentBatch = { batch_id, ...data }; + currentBatch = { batch_id, ...data, pollingMode: mode }; activeBatchId = batch_id; isOutlookBatchMode = (mode === 'outlook_batch'); batchCompleted = false; diff --git a/static/js/email_services.js b/static/js/email_services.js index 9583b2b5..919f4012 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'), @@ -49,6 +55,7 @@ const elements = { cancelAddCustom: document.getElementById('cancel-add-custom'), customSubType: document.getElementById('custom-sub-type'), addMoemailFields: document.getElementById('add-moemail-fields'), + addYydsMailFields: document.getElementById('add-yydsmail-fields'), addTempmailFields: document.getElementById('add-tempmail-fields'), addDuckmailFields: document.getElementById('add-duckmail-fields'), addFreemailFields: document.getElementById('add-freemail-fields'), @@ -60,6 +67,7 @@ const elements = { closeEditCustomModal: document.getElementById('close-edit-custom-modal'), cancelEditCustom: document.getElementById('cancel-edit-custom'), editMoemailFields: document.getElementById('edit-moemail-fields'), + editYydsMailFields: document.getElementById('edit-yydsmail-fields'), editTempmailFields: document.getElementById('edit-tempmail-fields'), editDuckmailFields: document.getElementById('edit-duckmail-fields'), editFreemailFields: document.getElementById('edit-freemail-fields'), @@ -75,6 +83,7 @@ const elements = { }; const CUSTOM_SUBTYPE_LABELS = { + yydsmail: 'YYDS Mail (YYDS Mail API)', moemail: '🔗 MoeMail(自定义域名 API)', tempmail: '📮 TempMail(自部署 Cloudflare Worker)', duckmail: '🦆 DuckMail(DuckMail API)', @@ -159,6 +168,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', () => { @@ -182,6 +193,7 @@ function closeEmailMoreMenu(el) { function switchAddSubType(subType) { elements.customSubType.value = subType; elements.addMoemailFields.style.display = subType === 'moemail' ? '' : 'none'; + elements.addYydsMailFields.style.display = subType === 'yydsmail' ? '' : 'none'; elements.addTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; elements.addDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; elements.addFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; @@ -192,6 +204,7 @@ function switchAddSubType(subType) { function switchEditSubType(subType) { elements.editCustomSubTypeHidden.value = subType; elements.editMoemailFields.style.display = subType === 'moemail' ? '' : 'none'; + elements.editYydsMailFields.style.display = subType === 'yydsmail' ? '' : 'none'; elements.editTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; elements.editDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; elements.editFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; @@ -204,7 +217,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.yyds_mail_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0) + (data.freemail_count || 0) + (data.imap_mail_count || 0); elements.tempmailStatus.textContent = data.tempmail_available ? '可用' : '不可用'; elements.totalEnabled.textContent = data.enabled_count || 0; } catch (error) { @@ -298,6 +311,9 @@ function getCustomServiceTypeBadge(subType) { if (subType === 'moemail') { return 'MoeMail'; } + if (subType === 'yydsmail') { + return 'YYDS Mail'; + } if (subType === 'tempmail') { return 'TempMail'; } @@ -327,8 +343,9 @@ function getCustomServiceAddress(service) { // 加载自定义邮箱服务(moe_mail + temp_mail + duck_mail + freemail 合并) async function loadCustomServices() { try { - const [r1, r2, r3, r4, r5] = await Promise.all([ + const [r1, r2, r3, r4, r5, r6] = await Promise.all([ api.get('/email-services?service_type=moe_mail'), + api.get('/email-services?service_type=yyds_mail'), api.get('/email-services?service_type=temp_mail'), api.get('/email-services?service_type=duck_mail'), api.get('/email-services?service_type=freemail'), @@ -336,10 +353,11 @@ async function loadCustomServices() { ]); customServices = [ ...(r1.services || []).map(s => ({ ...s, _subType: 'moemail' })), - ...(r2.services || []).map(s => ({ ...s, _subType: 'tempmail' })), - ...(r3.services || []).map(s => ({ ...s, _subType: 'duckmail' })), - ...(r4.services || []).map(s => ({ ...s, _subType: 'freemail' })), - ...(r5.services || []).map(s => ({ ...s, _subType: 'imap' })) + ...(r2.services || []).map(s => ({ ...s, _subType: 'yydsmail' })), + ...(r3.services || []).map(s => ({ ...s, _subType: 'tempmail' })), + ...(r4.services || []).map(s => ({ ...s, _subType: 'duckmail' })), + ...(r5.services || []).map(s => ({ ...s, _subType: 'freemail' })), + ...(r6.services || []).map(s => ({ ...s, _subType: 'imap' })) ]; if (customServices.length === 0) { @@ -401,9 +419,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) { // 忽略错误 } @@ -461,6 +487,13 @@ async function handleAddCustom(e) { api_key: formData.get('api_key'), default_domain: formData.get('domain') }; + } else if (subType === 'yydsmail') { + serviceType = 'yyds_mail'; + config = { + base_url: formData.get('yyds_base_url'), + api_key: formData.get('yyds_api_key'), + default_domain: formData.get('yyds_domain') + }; } else if (subType === 'tempmail') { serviceType = 'temp_mail'; config = { @@ -495,6 +528,11 @@ async function handleAddCustom(e) { }; } + if (subType === 'yydsmail' && (!config.base_url || !config.api_key)) { + toast.error('YYDS Mail 需要填写 API URL 和 API Key'); + return; + } + const data = { service_type: serviceType, name: formData.get('name'), @@ -584,6 +622,7 @@ async function handleSaveTempmail(e) { enabled: elements.tempmailEnabled.checked }); toast.success('配置已保存'); + loadStats(); } catch (error) { toast.error('保存失败: ' + error.message); } @@ -595,6 +634,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 +647,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; @@ -631,6 +730,8 @@ async function editCustomService(id, subType) { const resolvedSubType = subType || ( service.service_type === 'temp_mail' ? 'tempmail' + : service.service_type === 'yyds_mail' + ? 'yydsmail' : service.service_type === 'duck_mail' ? 'duckmail' : service.service_type === 'freemail' @@ -652,6 +753,11 @@ async function editCustomService(id, subType) { document.getElementById('edit-custom-api-key').value = ''; document.getElementById('edit-custom-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : 'API Key'; document.getElementById('edit-custom-domain').value = service.config?.default_domain || service.config?.domain || ''; + } else if (resolvedSubType === 'yydsmail') { + document.getElementById('edit-yyds-base-url').value = service.config?.base_url || ''; + document.getElementById('edit-yyds-api-key').value = ''; + document.getElementById('edit-yyds-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : 'Enter API Key'; + document.getElementById('edit-yyds-domain').value = service.config?.default_domain || service.config?.domain || ''; } else if (resolvedSubType === 'tempmail') { document.getElementById('edit-tm-base-url').value = service.config?.base_url || ''; document.getElementById('edit-tm-admin-password').value = ''; @@ -698,6 +804,13 @@ async function handleEditCustom(e) { }; const apiKey = formData.get('api_key'); if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); + } else if (subType === 'yydsmail') { + config = { + base_url: formData.get('yyds_base_url'), + default_domain: formData.get('yyds_domain') + }; + const apiKey = formData.get('yyds_api_key'); + if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); } else if (subType === 'tempmail') { config = { base_url: formData.get('tm_base_url'), 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/tags/v1.1.2.json b/tags/v1.1.2.json new file mode 100644 index 00000000..0f133601 --- /dev/null +++ b/tags/v1.1.2.json @@ -0,0 +1 @@ +{"tag_name": "v1.1.2", "commit": "3ab48fc79cbac31fdb90af12840fd6e97d63b480","date": "2026-03-26 21:31:26"} \ No newline at end of file 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 @@