diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..cc23192c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.bat text eol=crlf diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 874d40b5..2021a1d0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -65,4 +65,4 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + cache-to: type=gha,mode=max diff --git a/requirements.txt b/requirements.txt index 462b7fc7..58ab33a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ fastapi>=0.100.0 uvicorn[standard]>=0.23.0 jinja2>=3.1.0 python-multipart>=0.0.6 +requests>=2.32.5 sqlalchemy>=2.0.0 aiosqlite>=0.19.0 psycopg[binary]>=3.1.18 diff --git a/src/config/constants.py b/src/config/constants.py index 9e787a2e..a257a498 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -38,6 +38,7 @@ class EmailServiceType(str, Enum): DUCK_MAIL = "duck_mail" FREEMAIL = "freemail" IMAP_MAIL = "imap_mail" + CLOUD_MAIL = "cloud_mail" # ============================================================================ @@ -140,6 +141,14 @@ class EmailServiceType(str, Enum): "password": "", "timeout": 30, "max_retries": 3, + }, + "cloud_mail": { + "base_url": "", + "admin_email": "", + "admin_password": "", + "domain": "", + "timeout": 30, + "max_retries": 3, } } diff --git a/src/config/settings.py b/src/config/settings.py index 2803afd5..4fc94eea 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -346,7 +346,7 @@ class SettingDefinition: # 验证码配置 "email_code_timeout": SettingDefinition( db_key="email_code.timeout", - default_value=120, + default_value=30, category=SettingCategory.EMAIL, description="验证码等待超时时间(秒)" ), @@ -690,7 +690,7 @@ def proxy_url(self) -> Optional[str]: cpa_api_token: SecretStr = SecretStr("") # 验证码配置 - email_code_timeout: int = 120 + email_code_timeout: int = 30 email_code_poll_interval: int = 3 # Outlook 配置 diff --git a/src/core/register.py b/src/core/register.py index 8ca0e75f..13b9e39b 100644 --- a/src/core/register.py +++ b/src/core/register.py @@ -634,7 +634,7 @@ def _get_verification_code(self) -> Optional[str]: code = self.email_service.get_verification_code( email=self.email, email_id=email_id, - timeout=120, + timeout=30, pattern=OTP_CODE_PATTERN, otp_sent_at=self._otp_sent_at, ) diff --git a/src/services/__init__.py b/src/services/__init__.py index ad29d3e5..16d30535 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -17,6 +17,7 @@ from .duck_mail import DuckMailService from .freemail import FreemailService from .imap_mail import ImapMailService +from .cloud_mail import CloudMailService # 注册服务 EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService) @@ -26,6 +27,7 @@ EmailServiceFactory.register(EmailServiceType.DUCK_MAIL, DuckMailService) EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService) EmailServiceFactory.register(EmailServiceType.IMAP_MAIL, ImapMailService) +EmailServiceFactory.register(EmailServiceType.CLOUD_MAIL, CloudMailService) # 导出 Outlook 模块的额外内容 from .outlook.base import ( @@ -59,6 +61,7 @@ 'DuckMailService', 'FreemailService', 'ImapMailService', + 'CloudMailService', # Outlook 模块 'ProviderType', 'EmailMessage', diff --git a/src/services/cloud_mail.py b/src/services/cloud_mail.py new file mode 100644 index 00000000..e9e764c5 --- /dev/null +++ b/src/services/cloud_mail.py @@ -0,0 +1,529 @@ +""" +Cloud Mail 邮箱服务实现 +基于 Cloudflare Workers 的邮箱服务 (https://doc.skymail.ink) +""" + +import re +import sys +import time +import logging +import random +import string +import requests +from typing import Optional, Dict, Any, List +from datetime import datetime + +from .base import BaseEmailService, EmailServiceError, EmailServiceType +from ..config.constants import OTP_CODE_PATTERN + +logger = logging.getLogger(__name__) + + +class CloudMailService(BaseEmailService): + """ + Cloud Mail 邮箱服务 + 基于 Cloudflare Workers 的自部署邮箱服务 + """ + + # 类变量:所有实例共享token(按base_url区分) + _shared_tokens: Dict[str, tuple] = {} # {base_url: (token, expires_at)} + _token_lock = None # 延迟初始化 + _seen_ids_lock = None # seen_email_ids 的锁 + _shared_seen_email_ids: Dict[str, set] = {} # 所有实例共享已处理的邮件ID(按邮箱地址区分) + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + """ + 初始化 Cloud Mail 服务 + + Args: + config: 配置字典,支持以下键: + - base_url: API 基础地址 (必需) + - admin_email: 管理员邮箱 (必需) + - admin_password: 管理员密码 (必需) + - domain: 邮箱域名 (可选,用于生成邮箱地址) + - subdomain: 子域名 (可选),会插入到 @ 和域名之间,例如 subdomain="test" 会生成 xxx@test.example.com + - timeout: 请求超时时间,默认 30 + - max_retries: 最大重试次数,默认 3 + - proxy_url: 代理地址 (可选) + name: 服务名称 + """ + super().__init__(EmailServiceType.CLOUD_MAIL, name) + + required_keys = ["base_url", "admin_email", "admin_password"] + missing_keys = [key for key in required_keys if not (config or {}).get(key)] + if missing_keys: + raise ValueError(f"缺少必需配置: {missing_keys}") + + default_config = { + "timeout": 30, + "max_retries": 3, + "proxy_url": None, + } + self.config = {**default_config, **(config or {})} + self.config["base_url"] = self.config["base_url"].rstrip("/") + + # 创建 requests session + self.session = requests.Session() + self.session.headers.update({ + "Accept": "application/json", + "Content-Type": "application/json", + }) + + # 初始化类级别的锁(线程安全) + if CloudMailService._token_lock is None: + import threading + CloudMailService._token_lock = threading.Lock() + CloudMailService._seen_ids_lock = threading.Lock() + + # 缓存邮箱信息(实例级别) + self._created_emails: Dict[str, Dict[str, Any]] = {} + + def _generate_token(self) -> str: + """ + 生成身份令牌 + + Returns: + token 字符串 + + Raises: + EmailServiceError: 生成失败 + """ + url = f"{self.config['base_url']}/api/public/genToken" + payload = { + "email": self.config["admin_email"], + "password": self.config["admin_password"] + } + + try: + response = self.session.post( + url, + json=payload, + timeout=self.config["timeout"] + ) + + if response.status_code >= 400: + error_msg = f"生成 token 失败: {response.status_code}" + try: + error_data = response.json() + error_msg = f"{error_msg} - {error_data}" + except Exception: + error_msg = f"{error_msg} - {response.text[:200]}" + raise EmailServiceError(error_msg) + + data = response.json() + if data.get("code") != 200: + raise EmailServiceError(f"生成 token 失败: {data.get('message', 'Unknown error')}") + + token = data.get("data", {}).get("token") + if not token: + raise EmailServiceError("生成 token 失败: 未返回 token") + + return token + + except requests.RequestException as e: + self.update_status(False, e) + raise EmailServiceError(f"生成 token 失败: {e}") + except Exception as e: + self.update_status(False, e) + if isinstance(e, EmailServiceError): + raise + raise EmailServiceError(f"生成 token 失败: {e}") + + def _get_token(self, force_refresh: bool = False) -> str: + """ + 获取有效的 token(带缓存,所有实例共享) + + Args: + force_refresh: 是否强制刷新 + + Returns: + token 字符串 + """ + base_url = self.config["base_url"] + + with CloudMailService._token_lock: + # 检查共享缓存(token 有效期设为 1 小时) + if not force_refresh and base_url in CloudMailService._shared_tokens: + token, expires_at = CloudMailService._shared_tokens[base_url] + if time.time() < expires_at: + return token + + # 生成新 token + token = self._generate_token() + expires_at = time.time() + 3600 # 1 小时后过期 + CloudMailService._shared_tokens[base_url] = (token, expires_at) + return token + + def _get_headers(self, token: Optional[str] = None) -> Dict[str, str]: + """构造请求头""" + if token is None: + token = self._get_token() + + return { + "Authorization": token, + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _make_request( + self, + method: str, + path: str, + retry_on_auth_error: bool = True, + **kwargs + ) -> Any: + """ + 发送请求并返回 JSON 数据 + + Args: + method: HTTP 方法 + path: 请求路径(以 / 开头) + retry_on_auth_error: 认证失败时是否重试 + **kwargs: 传递给 requests 的额外参数 + + Returns: + 响应 JSON 数据 + + Raises: + EmailServiceError: 请求失败 + """ + url = f"{self.config['base_url']}{path}" + kwargs.setdefault("headers", {}) + kwargs["headers"].update(self._get_headers()) + kwargs.setdefault("timeout", self.config["timeout"]) + + try: + response = self.session.request(method, url, **kwargs) + + if response.status_code >= 400: + # 如果是认证错误且允许重试,刷新 token 后重试一次 + if response.status_code == 401 and retry_on_auth_error: + logger.warning("Cloud Mail 认证失败,尝试刷新 token") + kwargs["headers"].update(self._get_headers(self._get_token(force_refresh=True))) + response = self.session.request(method, url, **kwargs) + + if response.status_code >= 400: + error_msg = f"请求失败: {response.status_code}" + try: + error_data = response.json() + error_msg = f"{error_msg} - {error_data}" + except Exception: + error_msg = f"{error_msg} - {response.text[:200]}" + self.update_status(False, EmailServiceError(error_msg)) + raise EmailServiceError(error_msg) + + try: + return response.json() + except Exception: + return {"raw_response": response.text} + + except requests.RequestException as e: + self.update_status(False, e) + raise EmailServiceError(f"请求失败: {method} {path} - {e}") + except Exception as e: + self.update_status(False, e) + if isinstance(e, EmailServiceError): + raise + raise EmailServiceError(f"请求失败: {method} {path} - {e}") + + def _generate_email_address(self, prefix: Optional[str] = None, domain: Optional[str] = None, subdomain: Optional[str] = None) -> str: + """ + 生成邮箱地址 + + Args: + prefix: 邮箱前缀,如果不提供则随机生成 + domain: 指定域名,如果不提供则从配置中选择 + subdomain: 子域名,可选参数,会插入到 @ 和域名之间 + + Returns: + 完整的邮箱地址 + """ + if not prefix: + # 生成随机前缀:首字母 + 9位随机字符(共10位) + first = random.choice(string.ascii_lowercase) + rest = "".join(random.choices(string.ascii_lowercase + string.digits, k=9)) + prefix = f"{first}{rest}" + + # 如果没有指定域名,从配置中获取 + if not domain: + domain_config = self.config.get("domain") + if not domain_config: + raise EmailServiceError("未配置邮箱域名,无法生成邮箱地址") + + # 支持多个域名(列表)或单个域名(字符串) + if isinstance(domain_config, list): + if not domain_config: + raise EmailServiceError("域名列表为空") + # 随机选择一个域名 + domain = random.choice(domain_config) + else: + domain = domain_config + + # 如果提供了子域,插入到域名前面 + if subdomain: + domain = f"{subdomain}.{domain}" + + return f"{prefix}@{domain}" + + def _generate_password(self, length: int = 12) -> str: + """生成随机密码""" + alphabet = string.ascii_letters + string.digits + return "".join(random.choices(alphabet, k=length)) + + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + """ + 创建新邮箱地址 + + Args: + config: 配置参数: + - name: 邮箱前缀(可选) + - password: 邮箱密码(可选,不提供则自动生成) + - domain: 邮箱域名(可选,覆盖默认域名) + - subdomain: 子域名(可选),会插入到 @ 和域名之间,例如 subdomain="test" 会生成 xxx@test.example.com + + Returns: + 包含邮箱信息的字典: + - email: 邮箱地址 + - service_id: 邮箱地址(用作标识) + - password: 邮箱密码 + """ + req_config = config or {} + + # 生成邮箱地址 + prefix = req_config.get("name") + specified_domain = req_config.get("domain") + subdomain = req_config.get("subdomain") or self.config.get("subdomain") + + if specified_domain: + email_address = self._generate_email_address(prefix, specified_domain, subdomain) + else: + email_address = self._generate_email_address(prefix, subdomain=subdomain) + + # 生成或使用提供的密码 + password = req_config.get("password") or self._generate_password() + + # 直接生成邮箱信息(catch-all 域名无需预先创建) + email_info = { + "email": email_address, + "service_id": email_address, + "id": email_address, + "password": password, + "created_at": time.time(), + } + + # 缓存邮箱信息 + self._created_emails[email_address] = email_info + self.update_status(True) + + logger.info(f"生成 CloudMail 邮箱: {email_address}") + return email_info + + def get_verification_code( + self, + email: str, + email_id: str = None, + timeout: int = 120, + pattern: str = OTP_CODE_PATTERN, + otp_sent_at: Optional[float] = None, + ) -> Optional[str]: + """ + 从 Cloud Mail 邮箱获取验证码 + + Args: + email: 邮箱地址 + email_id: 未使用,保留接口兼容 + timeout: 超时时间(秒) + pattern: 验证码正则 + otp_sent_at: OTP 发送时间戳 + + Returns: + 验证码字符串,超时返回 None + """ + start_time = time.time() + + # 每次调用时,记录本次查询开始前已存在的邮件ID + # 这样可以支持同一个邮箱多次接收验证码(注册+OAuth) + initial_seen_ids = set() + with CloudMailService._seen_ids_lock: + if email not in CloudMailService._shared_seen_email_ids: + CloudMailService._shared_seen_email_ids[email] = set() + else: + # 记录本次查询开始前的已处理邮件 + initial_seen_ids = CloudMailService._shared_seen_email_ids[email].copy() + + # 本次查询中新处理的邮件ID(仅在本次查询中有效) + current_seen_ids = set() + + check_count = 0 + + while time.time() - start_time < timeout: + try: + check_count += 1 + + # 查询邮件列表 + url_path = "/api/public/emailList" + payload = { + "toEmail": email, + "timeSort": "desc" # 最新的邮件优先 + } + + result = self._make_request("POST", url_path, json=payload) + + if result.get("code") != 200: + time.sleep(3) + continue + + emails = result.get("data", []) + if not isinstance(emails, list): + time.sleep(3) + continue + + for email_item in emails: + email_id = email_item.get("emailId") + + if not email_id: + continue + + # 跳过本次查询开始前已存在的邮件 + if email_id in initial_seen_ids: + continue + + # 跳过本次查询中已处理的邮件(防止同一轮查询重复处理) + if email_id in current_seen_ids: + continue + + # 标记为本次已处理 + current_seen_ids.add(email_id) + + # 同时更新全局已处理列表(防止其他并发任务重复处理) + with CloudMailService._seen_ids_lock: + CloudMailService._shared_seen_email_ids[email].add(email_id) + + sender_email = str(email_item.get("sendEmail", "")).lower() + sender_name = str(email_item.get("sendName", "")).lower() + subject = str(email_item.get("subject", "")) + to_email = email_item.get("toEmail", "") + + # 检查收件人是否匹配 + if to_email != email: + continue + + if "openai" not in sender_email and "openai" not in sender_name: + continue + + # 从主题提取 + match = re.search(pattern, subject) + if match: + code = match.group(1) + self.update_status(True) + return code + + # 从内容提取 + content = str(email_item.get("content", "")) + if content: + clean_content = re.sub(r"<[^>]+>", " ", content) + email_pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" + clean_content = re.sub(email_pattern, "", clean_content) + + match = re.search(pattern, clean_content) + if match: + code = match.group(1) + self.update_status(True) + return code + + except Exception as e: + # 如果是认证错误,强制刷新token + if "401" in str(e) or "认证" in str(e): + try: + self._get_token(force_refresh=True) + except Exception: + pass + logger.error(f"检查邮件时出错: {e}", exc_info=True) + + time.sleep(3) + + # 超时 + logger.warning(f"等待验证码超时: {email}") + return None + + def list_emails(self, **kwargs) -> List[Dict[str, Any]]: + """ + 列出已创建的邮箱(从缓存中获取) + + Returns: + 邮箱列表 + """ + return list(self._created_emails.values()) + + def delete_email(self, email_id: str) -> bool: + """ + 删除邮箱(Cloud Mail API 不支持删除用户,仅从缓存中移除) + + Args: + email_id: 邮箱地址 + + Returns: + 是否删除成功 + """ + if email_id in self._created_emails: + del self._created_emails[email_id] + return True + + return False + + def check_health(self) -> bool: + """检查服务健康状态""" + try: + # 尝试生成 token + self._get_token(force_refresh=True) + self.update_status(True) + return True + except Exception as e: + logger.warning(f"Cloud Mail 健康检查失败: {e}") + self.update_status(False, e) + return False + + def get_email_messages(self, email_id: str, **kwargs) -> List[Dict[str, Any]]: + """ + 获取邮箱中的邮件列表 + + Args: + email_id: 邮箱地址 + **kwargs: 额外参数(如 timeSort) + + Returns: + 邮件列表 + """ + try: + url_path = "/api/public/emailList" + payload = { + "toEmail": email_id, + "timeSort": kwargs.get("timeSort", "desc") + } + + result = self._make_request("POST", url_path, json=payload) + + if result.get("code") != 200: + logger.warning(f"获取邮件列表失败: {result.get('message')}") + return [] + + self.update_status(True) + return result.get("data", []) + + except Exception as e: + logger.error(f"获取 Cloud Mail 邮件列表失败: {email_id} - {e}") + self.update_status(False, e) + return [] + + def get_service_info(self) -> Dict[str, Any]: + """获取服务信息""" + return { + "service_type": self.service_type.value, + "name": self.name, + "base_url": self.config["base_url"], + "admin_email": self.config["admin_email"], + "domain": self.config.get("domain"), + "subdomain": self.config.get("subdomain"), + "cached_emails_count": len(self._created_emails), + "status": self.status.value, + } diff --git a/src/web/app.py b/src/web/app.py index 09fb509e..99285591 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -108,9 +108,10 @@ def _redirect_to_login(request: Request) -> RedirectResponse: async def login_page(request: Request, next: Optional[str] = "/"): """登录页面""" return templates.TemplateResponse( - "login.html", - {"request": request, "error": "", "next": next or "/"} - ) + request=request, + name="login.html", + context={"error": "", "next": next or "/"} +) @app.post("/login") async def login_submit(request: Request, password: str = Form(...), next: Optional[str] = "/"): @@ -118,10 +119,11 @@ async def login_submit(request: Request, password: str = Form(...), next: Option expected = get_settings().webui_access_password.get_secret_value() if not secrets.compare_digest(password, expected): return templates.TemplateResponse( - "login.html", - {"request": request, "error": "密码错误", "next": next or "/"}, - status_code=401 - ) + request=request, + name="login.html", + context={"error": "密码错误", "next": next or "/"}, + status_code=401 +) response = RedirectResponse(url=next or "/", status_code=302) response.set_cookie("webui_auth", _auth_token(expected), httponly=True, samesite="lax") @@ -139,33 +141,33 @@ async def index(request: Request): """首页 - 注册页面""" if not _is_authenticated(request): return _redirect_to_login(request) - return templates.TemplateResponse("index.html", {"request": request}) + return templates.TemplateResponse(request=request, name="index.html") @app.get("/accounts", response_class=HTMLResponse) async def accounts_page(request: Request): """账号管理页面""" if not _is_authenticated(request): return _redirect_to_login(request) - return templates.TemplateResponse("accounts.html", {"request": request}) + return templates.TemplateResponse(request=request, name="accounts.html") @app.get("/email-services", response_class=HTMLResponse) async def email_services_page(request: Request): """邮箱服务管理页面""" if not _is_authenticated(request): return _redirect_to_login(request) - return templates.TemplateResponse("email_services.html", {"request": request}) + return templates.TemplateResponse(request=request, name="email_services.html") @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): """设置页面""" if not _is_authenticated(request): return _redirect_to_login(request) - return templates.TemplateResponse("settings.html", {"request": request}) + return templates.TemplateResponse(request=request, name="settings.html") @app.get("/payment", response_class=HTMLResponse) async def payment_page(request: Request): """支付页面""" - return templates.TemplateResponse("payment.html", {"request": request}) + return templates.TemplateResponse(request=request, name="payment.html") @app.on_event("startup") async def startup_event(): diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index daa92e5e..0200dd57 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -580,6 +580,7 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy: def _init_batch_state(batch_id: str, task_uuids: List[str]): """初始化批量任务内存状态""" + import time task_manager.init_batch(batch_id, len(task_uuids)) batch_tasks[batch_id] = { "total": len(task_uuids), @@ -590,7 +591,8 @@ def _init_batch_state(batch_id: str, task_uuids: List[str]): "task_uuids": task_uuids, "current_index": 0, "logs": [], - "finished": False + "finished": False, + "start_time": time.time(), # 记录开始时间 } @@ -659,9 +661,34 @@ async def _run_one(idx: int, uuid: str): update_batch_status(completed=new_completed, success=new_success, failed=new_failed) try: + import time + start_time = time.time() # 记录开始时间 + await asyncio.gather(*[_run_one(i, u) for i, u in enumerate(task_uuids)], return_exceptions=True) + + # 计算总耗时 + end_time = time.time() + total_seconds = end_time - start_time + if not task_manager.is_batch_cancelled(batch_id): - add_batch_log(f"[完成] 批量任务完成!成功: {batch_tasks[batch_id]['success']}, 失败: {batch_tasks[batch_id]['failed']}") + success_count = batch_tasks[batch_id]['success'] + failed_count = batch_tasks[batch_id]['failed'] + + # 计算平均每个账号的时间 + total_accounts = success_count + failed_count + avg_time = total_seconds / total_accounts if total_accounts > 0 else 0 + + # 格式化时间显示 + minutes = int(total_seconds // 60) + seconds = int(total_seconds % 60) + time_str = f"{minutes}分{seconds}秒" if minutes > 0 else f"{seconds}秒" + + if failed_count > 0: + add_batch_log(f"[完成] 批量任务完成!成功: {success_count}, 未成功: {failed_count}") + else: + add_batch_log(f"[完成] 批量任务完成!✅ 全部成功: {success_count} 个") + + add_batch_log(f"[统计] 总耗时: {time_str}, 平均每个账号: {avg_time:.1f}秒") update_batch_status(finished=True, status="completed") else: update_batch_status(finished=True, status="cancelled") @@ -727,6 +754,9 @@ async def _run_and_release(idx: int, uuid: str, pfx: str): semaphore.release() try: + import time + start_time = time.time() # 记录开始时间 + for i, task_uuid in enumerate(task_uuids): if task_manager.is_batch_cancelled(batch_id) or batch_tasks[batch_id]["cancelled"]: with get_db() as db: @@ -751,8 +781,29 @@ async def _run_and_release(idx: int, uuid: str, pfx: str): if running_tasks_list: await asyncio.gather(*running_tasks_list, return_exceptions=True) + # 计算总耗时 + end_time = time.time() + total_seconds = end_time - start_time + if not task_manager.is_batch_cancelled(batch_id): - add_batch_log(f"[完成] 批量任务完成!成功: {batch_tasks[batch_id]['success']}, 失败: {batch_tasks[batch_id]['failed']}") + success_count = batch_tasks[batch_id]['success'] + failed_count = batch_tasks[batch_id]['failed'] + + # 计算平均每个账号的时间 + total_accounts = success_count + failed_count + avg_time = total_seconds / total_accounts if total_accounts > 0 else 0 + + # 格式化时间显示 + minutes = int(total_seconds // 60) + seconds = int(total_seconds % 60) + time_str = f"{minutes}分{seconds}秒" if minutes > 0 else f"{seconds}秒" + + if failed_count > 0: + add_batch_log(f"[完成] 批量任务完成!成功: {success_count}, 未成功: {failed_count}") + else: + add_batch_log(f"[完成] 批量任务完成!✅ 全部成功: {success_count} 个") + + add_batch_log(f"[统计] 总耗时: {time_str}, 平均每个账号: {avg_time:.1f}秒") update_batch_status(finished=True, status="completed") except Exception as e: logger.error(f"批量任务 {batch_id} 异常: {e}") @@ -1129,6 +1180,11 @@ async def get_available_email_services(): "available": False, "count": 0, "services": [] + }, + "cloud_mail": { + "available": False, + "count": 0, + "services": [] } } @@ -1257,6 +1313,32 @@ async def get_available_email_services(): result["imap_mail"]["count"] = len(imap_mail_services) result["imap_mail"]["available"] = len(imap_mail_services) > 0 + # 获取 Cloud Mail 服务 + cloud_mail_services = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "cloud_mail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).all() + + for service in cloud_mail_services: + config = service.config or {} + domain = config.get("domain") + # 如果是列表,显示第一个域名 + if isinstance(domain, list) and domain: + domain_display = domain[0] + else: + domain_display = domain + + result["cloud_mail"]["services"].append({ + "id": service.id, + "name": service.name, + "type": "cloud_mail", + "domain": domain_display, + "priority": service.priority + }) + + result["cloud_mail"]["count"] = len(cloud_mail_services) + result["cloud_mail"]["available"] = len(cloud_mail_services) > 0 + return result diff --git a/static/js/app.js b/static/js/app.js index 543dc0b4..8bf5d5e4 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -24,7 +24,8 @@ let availableServices = { moe_mail: { available: false, services: [] }, temp_mail: { available: false, services: [] }, duck_mail: { available: false, services: [] }, - freemail: { available: false, services: [] } + freemail: { available: false, services: [] }, + cloud_mail: { available: false, services: [] } }; // WebSocket 相关变量 @@ -372,6 +373,23 @@ function updateEmailServiceOptions() { select.appendChild(optgroup); } + + // CloudMail + if (availableServices.cloud_mail && availableServices.cloud_mail.available) { + const optgroup = document.createElement('optgroup'); + optgroup.label = `☁️ CloudMail (${availableServices.cloud_mail.count} 个服务)`; + + availableServices.cloud_mail.services.forEach(service => { + const option = document.createElement('option'); + option.value = `cloud_mail:${service.id}`; + option.textContent = service.name + (service.domain ? ` (@${service.domain})` : ''); + option.dataset.type = 'cloud_mail'; + option.dataset.serviceId = service.id; + optgroup.appendChild(option); + }); + + select.appendChild(optgroup); + } } // 处理邮箱服务切换 @@ -422,6 +440,11 @@ function handleServiceChange(e) { if (service) { addLog('info', `[系统] 已选择 Freemail 服务: ${service.name}`); } + } else if (type === 'cloud_mail') { + const service = availableServices.cloud_mail.services.find(s => s.id == id); + if (service) { + addLog('info', `[系统] 已选择 CloudMail 服务: ${service.name}`); + } } } @@ -1049,7 +1072,8 @@ function resetButtons() { elements.cancelBtn.disabled = true; currentTask = null; currentBatch = null; - isBatchMode = false; + // 不要重置 isBatchMode,保持用户选择的模式 + // isBatchMode = false; // 重置完成标志 taskCompleted = false; batchCompleted = false; diff --git a/static/js/email_services.js b/static/js/email_services.js index fafd85b4..a6742c27 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -52,6 +52,7 @@ const elements = { addTempmailFields: document.getElementById('add-tempmail-fields'), addDuckmailFields: document.getElementById('add-duckmail-fields'), addFreemailFields: document.getElementById('add-freemail-fields'), + addCloudmailFields: document.getElementById('add-cloudmail-fields'), addImapFields: document.getElementById('add-imap-fields'), // 编辑自定义域名模态框 @@ -63,6 +64,7 @@ const elements = { editTempmailFields: document.getElementById('edit-tempmail-fields'), editDuckmailFields: document.getElementById('edit-duckmail-fields'), editFreemailFields: document.getElementById('edit-freemail-fields'), + editCloudmailFields: document.getElementById('edit-cloudmail-fields'), editImapFields: document.getElementById('edit-imap-fields'), editCustomTypeBadge: document.getElementById('edit-custom-type-badge'), editCustomSubTypeHidden: document.getElementById('edit-custom-sub-type-hidden'), @@ -79,6 +81,7 @@ const CUSTOM_SUBTYPE_LABELS = { tempmail: '📮 TempMail(自部署 Cloudflare Worker)', duckmail: '🦆 DuckMail(DuckMail API)', freemail: 'Freemail(自部署 Cloudflare Worker)', + cloudmail: '☁️ CloudMail(Cloudflare Workers 邮箱)', imap: '📧 IMAP 邮箱(Gmail/QQ/163等)' }; @@ -185,6 +188,7 @@ function switchAddSubType(subType) { elements.addTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; elements.addDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; elements.addFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; + elements.addCloudmailFields.style.display = subType === 'cloudmail' ? '' : 'none'; elements.addImapFields.style.display = subType === 'imap' ? '' : 'none'; } @@ -195,6 +199,7 @@ function switchEditSubType(subType) { elements.editTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; elements.editDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; elements.editFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; + elements.editCloudmailFields.style.display = subType === 'cloudmail' ? '' : 'none'; elements.editImapFields.style.display = subType === 'imap' ? '' : 'none'; elements.editCustomTypeBadge.textContent = CUSTOM_SUBTYPE_LABELS[subType] || CUSTOM_SUBTYPE_LABELS.moemail; } @@ -289,6 +294,9 @@ function getCustomServiceTypeBadge(subType) { if (subType === 'freemail') { return 'Freemail'; } + if (subType === 'cloudmail') { + return 'CloudMail'; + } return 'IMAP'; } @@ -309,19 +317,21 @@ 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=temp_mail'), api.get('/email-services?service_type=duck_mail'), api.get('/email-services?service_type=freemail'), - api.get('/email-services?service_type=imap_mail') + api.get('/email-services?service_type=imap_mail'), + api.get('/email-services?service_type=cloud_mail') ]); 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' })) + ...(r5.services || []).map(s => ({ ...s, _subType: 'imap' })), + ...(r6.services || []).map(s => ({ ...s, _subType: 'cloudmail' })) ]; if (customServices.length === 0) { @@ -466,6 +476,25 @@ async function handleAddCustom(e) { admin_token: formData.get('fm_admin_token'), domain: formData.get('fm_domain') }; + } else if (subType === 'cloudmail') { + serviceType = 'cloud_mail'; + const domainInput = formData.get('cm_domain'); + // 处理域名:如果包含逗号,转换为数组;否则保持字符串 + let domain = domainInput; + if (domainInput && domainInput.includes(',')) { + domain = domainInput.split(',').map(d => d.trim()).filter(d => d); + } + config = { + base_url: formData.get('cm_base_url'), + admin_email: formData.get('cm_admin_email'), + admin_password: formData.get('cm_admin_password'), + domain: domain + }; + // 添加子域配置(如果有) + const subdomain = formData.get('cm_subdomain'); + if (subdomain && subdomain.trim()) { + config.subdomain = subdomain.trim(); + } } else { serviceType = 'imap_mail'; config = { @@ -617,9 +646,11 @@ async function editCustomService(id, subType) { ? 'duckmail' : service.service_type === 'freemail' ? 'freemail' - : service.service_type === 'imap_mail' - ? 'imap' - : 'moemail' + : service.service_type === 'cloud_mail' + ? 'cloudmail' + : service.service_type === 'imap_mail' + ? 'imap' + : 'moemail' ); document.getElementById('edit-custom-id').value = service.id; @@ -650,6 +681,17 @@ async function editCustomService(id, subType) { document.getElementById('edit-fm-admin-token').value = ''; document.getElementById('edit-fm-admin-token').placeholder = service.config?.admin_token ? '已设置,留空保持不变' : '请输入 Admin Token'; document.getElementById('edit-fm-domain').value = service.config?.domain || ''; + } else if (resolvedSubType === 'cloudmail') { + document.getElementById('edit-cm-base-url').value = service.config?.base_url || ''; + document.getElementById('edit-cm-admin-email').value = service.config?.admin_email || ''; + document.getElementById('edit-cm-admin-password').value = ''; + document.getElementById('edit-cm-admin-password').placeholder = service.config?.admin_password ? '已设置,留空保持不变' : '请输入管理员密码'; + // 处理域名:如果是数组,转换为逗号分隔的字符串 + const domain = service.config?.domain; + const domainStr = Array.isArray(domain) ? domain.join(', ') : (domain || ''); + document.getElementById('edit-cm-domain').value = domainStr; + // 设置子域 + document.getElementById('edit-cm-subdomain').value = service.config?.subdomain || ''; } else { document.getElementById('edit-imap-host').value = service.config?.host || ''; document.getElementById('edit-imap-port').value = service.config?.port || 993; @@ -703,6 +745,25 @@ async function handleEditCustom(e) { }; const token = formData.get('fm_admin_token'); if (token && token.trim()) config.admin_token = token.trim(); + } else if (subType === 'cloudmail') { + const domainInput = formData.get('cm_domain'); + // 处理域名:如果包含逗号,转换为数组;否则保持字符串 + let domain = domainInput; + if (domainInput && domainInput.includes(',')) { + domain = domainInput.split(',').map(d => d.trim()).filter(d => d); + } + config = { + base_url: formData.get('cm_base_url'), + admin_email: formData.get('cm_admin_email'), + domain: domain + }; + // 添加子域配置(如果有) + const subdomain = formData.get('cm_subdomain'); + if (subdomain && subdomain.trim()) { + config.subdomain = subdomain.trim(); + } + const pwd = formData.get('cm_admin_password'); + if (pwd && pwd.trim()) config.admin_password = pwd.trim(); } else { config = { host: formData.get('imap_host'), diff --git a/templates/email_services.html b/templates/email_services.html index 4b18766f..60a0f04e 100644 --- a/templates/email_services.html +++ b/templates/email_services.html @@ -211,6 +211,7 @@

➕ 添加自定义邮箱服务

+ @@ -307,6 +308,31 @@

➕ 添加自定义邮箱服务

+ +
@@ -445,6 +471,32 @@

✏️ 编辑自定义邮箱服务

留空则保持原值不变
+ +