From 14706b37378034c12f0d4fa33b819cf3d77ecdee Mon Sep 17 00:00:00 2001 From: Ukumbuko Date: Mon, 23 Mar 2026 00:03:20 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E9=80=82=E9=85=8Dcloud-mail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 2 + requirements.txt | 1 + src/config/constants.py | 9 + src/config/settings.py | 4 +- src/core/register.py | 2 +- src/services/__init__.py | 3 + src/services/cloud_mail.py | 543 +++++++++++++++++++++++++++++++++ src/web/routes/registration.py | 88 +++++- static/js/app.js | 28 +- static/js/email_services.js | 61 +++- templates/email_services.html | 42 +++ 11 files changed, 769 insertions(+), 14 deletions(-) create mode 100644 .gitattributes create mode 100644 src/services/cloud_mail.py 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/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..bf5be4b1 --- /dev/null +++ b/src/services/cloud_mail.py @@ -0,0 +1,543 @@ +""" +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: 邮箱域名 (可选,用于生成邮箱地址) + - 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) -> str: + """ + 生成邮箱地址 + + Args: + prefix: 邮箱前缀,如果不提供则随机生成 + domain: 指定域名,如果不提供则从配置中选择 + + 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 + + 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: 邮箱域名(可选,覆盖默认域名) + + Returns: + 包含邮箱信息的字典: + - email: 邮箱地址 + - service_id: 邮箱地址(用作标识) + - password: 邮箱密码 + """ + req_config = config or {} + + # 生成邮箱地址 + prefix = req_config.get("name") + specified_domain = req_config.get("domain") + + if specified_domain: + # 使用指定的域名 + email_address = self._generate_email_address(prefix, specified_domain) + else: + # 使用配置中的域名 + email_address = self._generate_email_address(prefix) + + # 生成或使用提供的密码 + password = req_config.get("password") or self._generate_password() + + # 调用 API 添加用户 + url_path = "/api/public/addUser" + payload = { + "list": [ + { + "email": email_address, + "password": password + } + ] + } + + try: + result = self._make_request("POST", url_path, json=payload) + + if result.get("code") != 200: + raise EmailServiceError(f"创建邮箱失败: {result.get('message', 'Unknown error')}") + + 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) + return email_info + + except Exception as e: + self.update_status(False, e) + if isinstance(e, EmailServiceError): + raise + raise EmailServiceError(f"创建邮箱失败: {e}") + + 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"), + "cached_emails_count": len(self._created_emails), + "status": self.status.value, + } 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..c45b8574 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,20 @@ 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 + }; } else { serviceType = 'imap_mail'; config = { @@ -617,9 +641,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 +676,15 @@ 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; } else { document.getElementById('edit-imap-host').value = service.config?.host || ''; document.getElementById('edit-imap-port').value = service.config?.port || 993; @@ -703,6 +738,20 @@ 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 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..fe58e045 100644 --- a/templates/email_services.html +++ b/templates/email_services.html @@ -211,6 +211,7 @@

➕ 添加自定义邮箱服务

+ @@ -307,6 +308,26 @@

➕ 添加自定义邮箱服务

+ +
@@ -445,6 +466,27 @@

✏️ 编辑自定义邮箱服务

留空则保持原值不变
+ +
From fd8e525a901effa669484832516fcc45172f95c1 Mon Sep 17 00:00:00 2001 From: Ukumbuko Date: Tue, 31 Mar 2026 21:43:04 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E9=80=82=E9=85=8D=E5=AD=90=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/cloud_mail.py | 64 ++++++++++++++--------------------- static/js/email_services.js | 12 +++++++ templates/email_services.html | 10 ++++++ 3 files changed, 47 insertions(+), 39 deletions(-) diff --git a/src/services/cloud_mail.py b/src/services/cloud_mail.py index bf5be4b1..e9e764c5 100644 --- a/src/services/cloud_mail.py +++ b/src/services/cloud_mail.py @@ -41,6 +41,7 @@ def __init__(self, config: Dict[str, Any] = None, name: str = None): - admin_email: 管理员邮箱 (必需) - admin_password: 管理员密码 (必需) - domain: 邮箱域名 (可选,用于生成邮箱地址) + - subdomain: 子域名 (可选),会插入到 @ 和域名之间,例如 subdomain="test" 会生成 xxx@test.example.com - timeout: 请求超时时间,默认 30 - max_retries: 最大重试次数,默认 3 - proxy_url: 代理地址 (可选) @@ -225,13 +226,14 @@ def _make_request( raise raise EmailServiceError(f"请求失败: {method} {path} - {e}") - def _generate_email_address(self, prefix: Optional[str] = None, domain: Optional[str] = None) -> str: + def _generate_email_address(self, prefix: Optional[str] = None, domain: Optional[str] = None, subdomain: Optional[str] = None) -> str: """ 生成邮箱地址 Args: prefix: 邮箱前缀,如果不提供则随机生成 domain: 指定域名,如果不提供则从配置中选择 + subdomain: 子域名,可选参数,会插入到 @ 和域名之间 Returns: 完整的邮箱地址 @@ -257,6 +259,10 @@ def _generate_email_address(self, prefix: Optional[str] = None, domain: Optional else: domain = domain_config + # 如果提供了子域,插入到域名前面 + if subdomain: + domain = f"{subdomain}.{domain}" + return f"{prefix}@{domain}" def _generate_password(self, length: int = 12) -> str: @@ -273,6 +279,7 @@ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: - name: 邮箱前缀(可选) - password: 邮箱密码(可选,不提供则自动生成) - domain: 邮箱域名(可选,覆盖默认域名) + - subdomain: 子域名(可选),会插入到 @ 和域名之间,例如 subdomain="test" 会生成 xxx@test.example.com Returns: 包含邮箱信息的字典: @@ -285,53 +292,31 @@ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: # 生成邮箱地址 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) + email_address = self._generate_email_address(prefix, specified_domain, subdomain) else: - # 使用配置中的域名 - email_address = self._generate_email_address(prefix) + email_address = self._generate_email_address(prefix, subdomain=subdomain) # 生成或使用提供的密码 password = req_config.get("password") or self._generate_password() - # 调用 API 添加用户 - url_path = "/api/public/addUser" - payload = { - "list": [ - { - "email": email_address, - "password": password - } - ] + # 直接生成邮箱信息(catch-all 域名无需预先创建) + email_info = { + "email": email_address, + "service_id": email_address, + "id": email_address, + "password": password, + "created_at": time.time(), } - try: - result = self._make_request("POST", url_path, json=payload) - - if result.get("code") != 200: - raise EmailServiceError(f"创建邮箱失败: {result.get('message', 'Unknown error')}") - - 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) - return email_info - - except Exception as e: - self.update_status(False, e) - if isinstance(e, EmailServiceError): - raise - raise EmailServiceError(f"创建邮箱失败: {e}") + # 缓存邮箱信息 + 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, @@ -538,6 +523,7 @@ def get_service_info(self) -> Dict[str, Any]: "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/static/js/email_services.js b/static/js/email_services.js index c45b8574..a6742c27 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -490,6 +490,11 @@ async function handleAddCustom(e) { 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 = { @@ -685,6 +690,8 @@ async function editCustomService(id, subType) { 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; @@ -750,6 +757,11 @@ async function handleEditCustom(e) { 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 { diff --git a/templates/email_services.html b/templates/email_services.html index fe58e045..60a0f04e 100644 --- a/templates/email_services.html +++ b/templates/email_services.html @@ -327,6 +327,11 @@

➕ 添加自定义邮箱服务

支持多个域名,用英文逗号分隔
+
+ + + 例如填写 "test",生成的邮箱为 xxx@test.example.com +
@@ -486,6 +491,11 @@

✏️ 编辑自定义邮箱服务

支持多个域名,用英文逗号分隔
+
+ + + 例如填写 "test",生成的邮箱为 xxx@test.example.com +
From 0f175800c72d6e8cb602f17520a276098dd145b6 Mon Sep 17 00:00:00 2001 From: loong Date: Wed, 1 Apr 2026 20:31:50 +0800 Subject: [PATCH 3/6] fix --- requirements.txt | 1 + src/web/app.py | 33 +++++++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/requirements.txt b/requirements.txt index 58ab33a1..3dfb17d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ pycparser>=1.21 pydantic>=2.0.0 pydantic-settings>=2.0.0 fastapi>=0.100.0 +starlette<1.0.0 uvicorn[standard]>=0.23.0 jinja2>=3.1.0 python-multipart>=0.0.6 diff --git a/src/web/app.py b/src/web/app.py index 09fb509e..19a79725 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -11,7 +11,7 @@ from typing import Optional from pathlib import Path -from fastapi import FastAPI, Request, Form +from fastapi import FastAPI, Request, Form, Query from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.middleware.cors import CORSMiddleware @@ -105,32 +105,49 @@ def _redirect_to_login(request: Request) -> RedirectResponse: return RedirectResponse(url=f"/login?next={request.url.path}", status_code=302) @app.get("/login", response_class=HTMLResponse) - async def login_page(request: Request, next: Optional[str] = "/"): + async def login_page(request: Request, next: Optional[str] = Query(default="/")): """登录页面""" + # 确保 next 是字符串类型,处理可能的数组参数 + if isinstance(next, list): + next = next[0] if next else "/" + next = str(next) if next else "/" return templates.TemplateResponse( "login.html", - {"request": request, "error": "", "next": next or "/"} + {"request": request, "error": "", "next": next} ) @app.post("/login") - async def login_submit(request: Request, password: str = Form(...), next: Optional[str] = "/"): + async def login_submit( + request: Request, + password: str = Form(...), + next: Optional[str] = Form(default="/") + ): """处理登录提交""" + # 确保 next 是字符串类型 + if isinstance(next, list): + next = next[0] if next else "/" + next = str(next) if next else "/" + 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 "/"}, + {"request": request, "error": "密码错误", "next": next}, status_code=401 ) - response = RedirectResponse(url=next or "/", status_code=302) + response = RedirectResponse(url=next, status_code=302) response.set_cookie("webui_auth", _auth_token(expected), httponly=True, samesite="lax") return response @app.get("/logout") - async def logout(request: Request, next: Optional[str] = "/login"): + async def logout(request: Request, next: Optional[str] = Query(default="/login")): """退出登录""" - response = RedirectResponse(url=next or "/login", status_code=302) + # 确保 next 是字符串类型 + if isinstance(next, list): + next = next[0] if next else "/login" + next = str(next) if next else "/login" + response = RedirectResponse(url=next, status_code=302) response.delete_cookie("webui_auth") return response From 5e78d4520240c41f59d8fd2d81b3ab08ba36220b Mon Sep 17 00:00:00 2001 From: loong Date: Wed, 1 Apr 2026 20:39:34 +0800 Subject: [PATCH 4/6] update --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3dfb17d6..e3fac2f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ pycparser>=1.21 pydantic>=2.0.0 pydantic-settings>=2.0.0 fastapi>=0.100.0 -starlette<1.0.0 +starlette>=0.40.0,<1.0.0 uvicorn[standard]>=0.23.0 jinja2>=3.1.0 python-multipart>=0.0.6 From fdd32dcd9da3126edf00c58ffbba17936c6e863f Mon Sep 17 00:00:00 2001 From: loong Date: Thu, 2 Apr 2026 09:16:03 +0800 Subject: [PATCH 5/6] Add cloudflare_forward_imap service. --- src/config/constants.py | 16 ++ src/services/__init__.py | 2 + src/services/cloudflare_forward_imap.py | 319 ++++++++++++++++++++++++ src/web/routes/email.py | 22 ++ src/web/routes/registration.py | 46 ++++ static/js/app.js | 19 +- static/js/email_services.js | 53 +++- static/js/utils.js | 3 +- templates/email_services.html | 68 +++++ 9 files changed, 541 insertions(+), 7 deletions(-) create mode 100644 src/services/cloudflare_forward_imap.py diff --git a/src/config/constants.py b/src/config/constants.py index a257a498..a4093d23 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -39,6 +39,7 @@ class EmailServiceType(str, Enum): FREEMAIL = "freemail" IMAP_MAIL = "imap_mail" CLOUD_MAIL = "cloud_mail" + CLOUDFLARE_FORWARD_IMAP = "cloudflare_forward_imap" # ============================================================================ @@ -149,6 +150,21 @@ class EmailServiceType(str, Enum): "domain": "", "timeout": 30, "max_retries": 3, + }, + "cloudflare_forward_imap": { + "host": "", + "port": 993, + "use_ssl": True, + "real_email": "", + "password": "", + "folder": "INBOX", + "domains": [], + "poll_interval": 3, + "timeout": 30, + "max_retries": 3, + "require_openai_sender": True, + "recipient_headers_priority": ["Delivered-To", "X-Envelope-To", "To", "X-Original-To"], + "mark_seen_on_match": True, } } diff --git a/src/services/__init__.py b/src/services/__init__.py index 16d30535..07040fdc 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -18,6 +18,7 @@ from .freemail import FreemailService from .imap_mail import ImapMailService from .cloud_mail import CloudMailService +from .cloudflare_forward_imap import CloudflareForwardImapService # 注册服务 EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService) @@ -28,6 +29,7 @@ EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService) EmailServiceFactory.register(EmailServiceType.IMAP_MAIL, ImapMailService) EmailServiceFactory.register(EmailServiceType.CLOUD_MAIL, CloudMailService) +EmailServiceFactory.register(EmailServiceType.CLOUDFLARE_FORWARD_IMAP, CloudflareForwardImapService) # 导出 Outlook 模块的额外内容 from .outlook.base import ( diff --git a/src/services/cloudflare_forward_imap.py b/src/services/cloudflare_forward_imap.py new file mode 100644 index 00000000..c059f4d8 --- /dev/null +++ b/src/services/cloudflare_forward_imap.py @@ -0,0 +1,319 @@ +""" +Cloudflare Email Routing + IMAP 邮箱服务 +通过随机虚拟邮箱地址配合 Cloudflare 转发,最终从单个真实邮箱中读取验证码。 +""" + +import email +import imaplib +import logging +import random +import re +import string +import time +from email.header import decode_header +from email.utils import getaddresses, parsedate_to_datetime +from typing import Any, Dict, List, Optional + +from .base import BaseEmailService +from ..config.constants import ( + EmailServiceType, + OPENAI_EMAIL_SENDERS, + OTP_CODE_PATTERN, + OTP_CODE_SEMANTIC_PATTERN, +) + +logger = logging.getLogger(__name__) + + +class CloudflareForwardImapService(BaseEmailService): + """Cloudflare 转发 + IMAP 虚拟邮箱验证码服务""" + + DEFAULT_RECIPIENT_HEADERS = [ + "Delivered-To", + "X-Envelope-To", + "To", + "X-Original-To", + ] + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + super().__init__(EmailServiceType.CLOUDFLARE_FORWARD_IMAP, name) + + cfg = config or {} + required_keys = ["host", "real_email", "password", "domains"] + missing_keys = [key for key in required_keys if not cfg.get(key)] + if missing_keys: + raise ValueError(f"缺少必需配置: {missing_keys}") + + self.host: str = str(cfg["host"]).strip() + self.port: int = int(cfg.get("port", 993)) + self.use_ssl: bool = bool(cfg.get("use_ssl", True)) + self.real_email: str = str(cfg["real_email"]).strip() + self.password: str = str(cfg["password"]) + self.folder: str = str(cfg.get("folder") or "INBOX").strip() or "INBOX" + self.timeout: int = int(cfg.get("timeout", 30)) + self.max_retries: int = int(cfg.get("max_retries", 3)) + self.poll_interval: int = max(1, int(cfg.get("poll_interval", 3))) + self.require_openai_sender: bool = bool(cfg.get("require_openai_sender", True)) + self.mark_seen_on_match: bool = bool(cfg.get("mark_seen_on_match", True)) + self.domains: List[str] = self._normalize_domains(cfg.get("domains")) + self.recipient_headers_priority: List[str] = self._normalize_recipient_headers( + cfg.get("recipient_headers_priority") + ) + + if not self.domains: + raise ValueError("缺少可用域名配置") + + def _normalize_domains(self, domains: Any) -> List[str]: + if isinstance(domains, str): + raw_items = re.split(r"[\n,]+", domains) + elif isinstance(domains, list): + raw_items = domains + else: + raw_items = [] + + normalized: List[str] = [] + for item in raw_items: + value = str(item or "").strip().lstrip("@") + if value and value not in normalized: + normalized.append(value) + return normalized + + def _normalize_recipient_headers(self, headers: Any) -> List[str]: + if isinstance(headers, str): + raw_items = re.split(r"[\n,]+", headers) + elif isinstance(headers, list): + raw_items = headers + else: + raw_items = self.DEFAULT_RECIPIENT_HEADERS + + normalized: List[str] = [] + for item in raw_items: + value = str(item or "").strip() + if value and value not in normalized: + normalized.append(value) + return normalized or list(self.DEFAULT_RECIPIENT_HEADERS) + + def _generate_local_part(self) -> str: + first = random.choice(string.ascii_lowercase) + rest = "".join(random.choices(string.ascii_lowercase + string.digits, k=9)) + return f"{first}{rest}" + + def _connect(self) -> imaplib.IMAP4: + if self.use_ssl: + mail = imaplib.IMAP4_SSL(self.host, self.port) + else: + mail = imaplib.IMAP4(self.host, self.port) + mail.starttls() + mail.login(self.real_email, self.password) + return mail + + def _decode_str(self, value: Any) -> str: + if value is None: + return "" + parts = decode_header(value) + decoded = [] + for part, charset in parts: + if isinstance(part, bytes): + decoded.append(part.decode(charset or "utf-8", errors="replace")) + else: + decoded.append(str(part)) + return " ".join(decoded) + + def _get_text_body(self, msg) -> str: + body = "" + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_maintype() == "multipart": + continue + if part.get_content_type() != "text/plain": + continue + charset = part.get_content_charset() or "utf-8" + payload = part.get_payload(decode=True) + if payload: + body += payload.decode(charset, errors="replace") + else: + charset = msg.get_content_charset() or "utf-8" + payload = msg.get_payload(decode=True) + if payload: + body = payload.decode(charset, errors="replace") + return body + + def _is_openai_sender(self, from_addr: str) -> bool: + from_lower = from_addr.lower() + for sender in OPENAI_EMAIL_SENDERS: + if sender in from_lower: + return True + return False + + def _extract_otp(self, text: str, pattern: Optional[str] = None) -> Optional[str]: + semantic_match = re.search(OTP_CODE_SEMANTIC_PATTERN, text, re.IGNORECASE) + if semantic_match: + return semantic_match.group(1) + + target_pattern = pattern or OTP_CODE_PATTERN + simple_match = re.search(target_pattern, text) + if simple_match: + return simple_match.group(1) + return None + + def _extract_recipient_addresses(self, msg, header_name: str) -> List[str]: + values = msg.get_all(header_name, []) + recipients: List[str] = [] + + for value in values: + decoded_value = self._decode_str(value) + parsed_addresses = [ + addr.strip().lower() for _, addr in getaddresses([decoded_value]) if addr + ] + if parsed_addresses: + for address in parsed_addresses: + if address not in recipients: + recipients.append(address) + continue + + fallback_addresses = re.findall( + r"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+", + decoded_value, + ) + for address in fallback_addresses: + normalized = address.strip().lower() + if normalized and normalized not in recipients: + recipients.append(normalized) + + return recipients + + def _match_target_email(self, msg, target_email: str) -> bool: + target = str(target_email or "").strip().lower() + if not target: + return False + + for header_name in self.recipient_headers_priority: + recipients = self._extract_recipient_addresses(msg, header_name) + if target in recipients: + return True + return False + + def _parse_message_timestamp(self, msg) -> Optional[float]: + date_header = msg.get("Date") + if not date_header: + return None + try: + return parsedate_to_datetime(date_header).timestamp() + except Exception: + return None + + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + request_config = config or {} + local_part = str(request_config.get("name") or self._generate_local_part()).strip() + domain = str(request_config.get("domain") or random.choice(self.domains)).strip().lstrip("@") + address = f"{local_part}@{domain}" + + self.update_status(True) + return { + "email": address, + "service_id": address, + "id": address, + "created_at": time.time(), + } + + def get_verification_code( + self, + email: str, + email_id: str = None, + timeout: int = 60, + pattern: str = None, + otp_sent_at: Optional[float] = None, + ) -> Optional[str]: + start_time = time.time() + seen_ids: set = set() + mail = None + search_timeout = int(timeout or self.timeout or 60) + + try: + mail = self._connect() + mail.select(self.folder) + + while time.time() - start_time < search_timeout: + try: + status, data = mail.search(None, "UNSEEN") + if status != "OK" or not data or not data[0]: + time.sleep(self.poll_interval) + continue + + msg_ids = data[0].split() + for msg_id in reversed(msg_ids): + id_str = msg_id.decode(errors="ignore") + if id_str in seen_ids: + continue + seen_ids.add(id_str) + + status, msg_data = mail.fetch(msg_id, "(RFC822)") + if status != "OK" or not msg_data: + continue + + raw = msg_data[0][1] + msg = email.message_from_bytes(raw) + + if not self._match_target_email(msg, email): + continue + + if otp_sent_at is not None: + message_ts = self._parse_message_timestamp(msg) + if message_ts is not None and message_ts + 1 < float(otp_sent_at): + continue + + from_addr = self._decode_str(msg.get("From", "")) + if self.require_openai_sender and not self._is_openai_sender(from_addr): + continue + + body = self._get_text_body(msg) + code = self._extract_otp(body, pattern=pattern) + if code: + if self.mark_seen_on_match: + mail.store(msg_id, "+FLAGS", "\\Seen") + self.update_status(True) + logger.info(f"Cloudflare Forward IMAP 获取验证码成功: {code}") + return code + + except imaplib.IMAP4.error as e: + logger.debug(f"Cloudflare Forward IMAP 搜索邮件失败: {e}") + try: + mail.select(self.folder) + except Exception: + pass + + time.sleep(self.poll_interval) + + except Exception as e: + logger.warning(f"Cloudflare Forward IMAP 连接/轮询失败: {e}") + self.update_status(False, str(e)) + finally: + if mail: + try: + mail.logout() + except Exception: + pass + + return None + + def check_health(self) -> bool: + mail = None + try: + mail = self._connect() + status, _ = mail.select(self.folder) + return status == "OK" + except Exception as e: + logger.warning(f"Cloudflare Forward IMAP 健康检查失败: {e}") + return False + finally: + if mail: + try: + mail.logout() + except Exception: + pass + + def list_emails(self, **kwargs) -> List[Dict[str, Any]]: + return [] + + def delete_email(self, email_id: str) -> bool: + return True diff --git a/src/web/routes/email.py b/src/web/routes/email.py index 5f0123cf..a3c87e72 100644 --- a/src/web/routes/email.py +++ b/src/web/routes/email.py @@ -147,6 +147,7 @@ async def get_email_services_stats(): 'duck_mail_count': 0, 'freemail_count': 0, 'imap_mail_count': 0, + 'cloudflare_forward_imap_count': 0, 'tempmail_available': True, # 临时邮箱始终可用 'enabled_count': enabled_count } @@ -164,6 +165,8 @@ async def get_email_services_stats(): stats['freemail_count'] = count elif service_type == 'imap_mail': stats['imap_mail_count'] = count + elif service_type == 'cloudflare_forward_imap': + stats['cloudflare_forward_imap_count'] = count return stats @@ -246,6 +249,25 @@ async def get_service_types(): {"name": "email", "label": "邮箱地址", "required": True}, {"name": "password", "label": "密码/授权码", "required": True, "secret": True}, ] + }, + { + "value": "cloudflare_forward_imap", + "label": "Cloudflare 转发 IMAP", + "description": "Cloudflare Email Routing 转发到单个真实邮箱,通过 IMAP 读取虚拟邮箱验证码", + "config_fields": [ + {"name": "host", "label": "IMAP 服务器", "required": True, "placeholder": "imap.qq.com"}, + {"name": "port", "label": "端口", "required": False, "default": 993}, + {"name": "use_ssl", "label": "使用 SSL", "required": False, "default": True}, + {"name": "real_email", "label": "真实邮箱地址", "required": True}, + {"name": "password", "label": "密码/授权码", "required": True, "secret": True}, + {"name": "folder", "label": "邮箱文件夹", "required": False, "default": "INBOX"}, + {"name": "domains", "label": "虚拟邮箱域名列表", "required": True, "placeholder": "domain1.com,domain2.com"}, + {"name": "poll_interval", "label": "轮询间隔(秒)", "required": False, "default": 3}, + {"name": "timeout", "label": "超时时间(秒)", "required": False, "default": 30}, + {"name": "recipient_headers_priority", "label": "收件人头优先级", "required": False, "placeholder": "Delivered-To,X-Envelope-To,To,X-Original-To"}, + {"name": "require_openai_sender", "label": "仅匹配 OpenAI 发件人", "required": False, "default": True}, + {"name": "mark_seen_on_match", "label": "匹配后标记已读", "required": False, "default": True}, + ] } ] } diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index 0200dd57..51336d9d 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -214,6 +214,11 @@ def _normalize_email_service_config( elif service_type == EmailServiceType.DUCK_MAIL: if 'domain' in normalized and 'default_domain' not in normalized: normalized['default_domain'] = normalized.pop('domain') + elif service_type == EmailServiceType.CLOUDFLARE_FORWARD_IMAP: + if isinstance(normalized.get('domains'), str): + normalized['domains'] = [d.strip() for d in normalized['domains'].split(',') if d.strip()] + if isinstance(normalized.get('recipient_headers_priority'), str): + normalized['recipient_headers_priority'] = [h.strip() for h in normalized['recipient_headers_priority'].split(',') if h.strip()] if proxy_url and 'proxy_url' not in normalized: normalized['proxy_url'] = proxy_url @@ -386,6 +391,20 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: logger.info(f"使用数据库 IMAP 邮箱服务: {db_service.name}") else: raise ValueError("没有可用的 IMAP 邮箱服务,请先在邮箱服务中添加") + elif service_type == EmailServiceType.CLOUDFLARE_FORWARD_IMAP: + from ...database.models import EmailService as EmailServiceModel + + db_service = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "cloudflare_forward_imap", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).first() + + if db_service and db_service.config: + config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url) + crud.update_registration_task(db, task_uuid, email_service_id=db_service.id) + logger.info(f"使用数据库 Cloudflare Forward IMAP 服务: {db_service.name}") + else: + raise ValueError("没有可用的 Cloudflare Forward IMAP 服务,请先在邮箱服务中添加") else: config = email_service_config or {} @@ -1185,6 +1204,11 @@ async def get_available_email_services(): "available": False, "count": 0, "services": [] + }, + "cloudflare_forward_imap": { + "available": False, + "count": 0, + "services": [] } } @@ -1339,6 +1363,28 @@ async def get_available_email_services(): result["cloud_mail"]["count"] = len(cloud_mail_services) result["cloud_mail"]["available"] = len(cloud_mail_services) > 0 + # 获取 Cloudflare Forward IMAP 服务 + cloudflare_forward_imap_services = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "cloudflare_forward_imap", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).all() + + for service in cloudflare_forward_imap_services: + config = service.config or {} + domains = config.get("domains", []) + domain_display = domains[0] if isinstance(domains, list) and domains else str(domains) if domains else "" + + result["cloudflare_forward_imap"]["services"].append({ + "id": service.id, + "name": service.name, + "type": "cloudflare_forward_imap", + "domains": domain_display, + "priority": service.priority + }) + + result["cloudflare_forward_imap"]["count"] = len(cloudflare_forward_imap_services) + result["cloudflare_forward_imap"]["available"] = len(cloudflare_forward_imap_services) > 0 + return result diff --git a/static/js/app.js b/static/js/app.js index 8bf5d5e4..0a72ab30 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -25,7 +25,8 @@ let availableServices = { temp_mail: { available: false, services: [] }, duck_mail: { available: false, services: [] }, freemail: { available: false, services: [] }, - cloud_mail: { available: false, services: [] } + cloud_mail: { available: false, services: [] }, + cloudflare_forward_imap: { available: false, services: [] } }; // WebSocket 相关变量 @@ -390,6 +391,22 @@ function updateEmailServiceOptions() { select.appendChild(optgroup); } + + if (availableServices.cloudflare_forward_imap && availableServices.cloudflare_forward_imap.available) { + const optgroup = document.createElement('optgroup'); + optgroup.label = `📬 Cloudflare 转发 IMAP (${availableServices.cloudflare_forward_imap.count} 个服务)`; + + availableServices.cloudflare_forward_imap.services.forEach(service => { + const option = document.createElement('option'); + option.value = `cloudflare_forward_imap:${service.id}`; + option.textContent = service.name + (service.domains ? ` (@${service.domains})` : ''); + option.dataset.type = 'cloudflare_forward_imap'; + option.dataset.serviceId = service.id; + optgroup.appendChild(option); + }); + + select.appendChild(optgroup); + } } // 处理邮箱服务切换 diff --git a/static/js/email_services.js b/static/js/email_services.js index a6742c27..8b712a00 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -82,7 +82,8 @@ const CUSTOM_SUBTYPE_LABELS = { duckmail: '🦆 DuckMail(DuckMail API)', freemail: 'Freemail(自部署 Cloudflare Worker)', cloudmail: '☁️ CloudMail(Cloudflare Workers 邮箱)', - imap: '📧 IMAP 邮箱(Gmail/QQ/163等)' + imap: '📧 IMAP 邮箱(Gmail/QQ/163等)', + cloudflare_forward_imap: '📬 Cloudflare 转发 IMAP' }; // 初始化 @@ -190,6 +191,8 @@ function switchAddSubType(subType) { elements.addFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; elements.addCloudmailFields.style.display = subType === 'cloudmail' ? '' : 'none'; elements.addImapFields.style.display = subType === 'imap' ? '' : 'none'; + const cfiFields = document.getElementById('add-cloudflare-forward-imap-fields'); + if (cfiFields) cfiFields.style.display = subType === 'cloudflare_forward_imap' ? '' : 'none'; } // 切换编辑表单子类型显示 @@ -201,6 +204,8 @@ function switchEditSubType(subType) { elements.editFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; elements.editCloudmailFields.style.display = subType === 'cloudmail' ? '' : 'none'; elements.editImapFields.style.display = subType === 'imap' ? '' : 'none'; + const cfiFields = document.getElementById('edit-cloudflare-forward-imap-fields'); + if (cfiFields) cfiFields.style.display = subType === 'cloudflare_forward_imap' ? '' : 'none'; elements.editCustomTypeBadge.textContent = CUSTOM_SUBTYPE_LABELS[subType] || CUSTOM_SUBTYPE_LABELS.moemail; } @@ -317,13 +322,14 @@ function getCustomServiceAddress(service) { // 加载自定义邮箱服务(moe_mail + temp_mail + duck_mail + freemail 合并) async function loadCustomServices() { try { - const [r1, r2, r3, r4, r5, r6] = await Promise.all([ + const [r1, r2, r3, r4, r5, r6, r7] = 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=cloud_mail') + api.get('/email-services?service_type=cloud_mail'), + api.get('/email-services?service_type=cloudflare_forward_imap') ]); customServices = [ ...(r1.services || []).map(s => ({ ...s, _subType: 'moemail' })), @@ -331,7 +337,8 @@ async function loadCustomServices() { ...(r3.services || []).map(s => ({ ...s, _subType: 'duckmail' })), ...(r4.services || []).map(s => ({ ...s, _subType: 'freemail' })), ...(r5.services || []).map(s => ({ ...s, _subType: 'imap' })), - ...(r6.services || []).map(s => ({ ...s, _subType: 'cloudmail' })) + ...(r6.services || []).map(s => ({ ...s, _subType: 'cloudmail' })), + ...(r7.services || []).map(s => ({ ...s, _subType: 'cloudflare_forward_imap' })) ]; if (customServices.length === 0) { @@ -495,6 +502,18 @@ async function handleAddCustom(e) { if (subdomain && subdomain.trim()) { config.subdomain = subdomain.trim(); } + } else if (subType === 'cloudflare_forward_imap') { + serviceType = 'cloudflare_forward_imap'; + const domainsInput = formData.get('cfi_domains'); + const domains = domainsInput ? domainsInput.split(',').map(d => d.trim()).filter(d => d) : []; + config = { + host: formData.get('cfi_host'), + port: parseInt(formData.get('cfi_port'), 10) || 993, + use_ssl: formData.get('cfi_use_ssl') !== 'false', + real_email: formData.get('cfi_real_email'), + password: formData.get('cfi_password'), + domains: domains + }; } else { serviceType = 'imap_mail'; config = { @@ -650,7 +669,9 @@ async function editCustomService(id, subType) { ? 'cloudmail' : service.service_type === 'imap_mail' ? 'imap' - : 'moemail' + : service.service_type === 'cloudflare_forward_imap' + ? 'cloudflare_forward_imap' + : 'moemail' ); document.getElementById('edit-custom-id').value = service.id; @@ -692,6 +713,16 @@ async function editCustomService(id, subType) { document.getElementById('edit-cm-domain').value = domainStr; // 设置子域 document.getElementById('edit-cm-subdomain').value = service.config?.subdomain || ''; + } else if (resolvedSubType === 'cloudflare_forward_imap') { + document.getElementById('edit-cfi-host').value = service.config?.host || ''; + document.getElementById('edit-cfi-port').value = service.config?.port || 993; + document.getElementById('edit-cfi-use-ssl').value = service.config?.use_ssl !== false ? 'true' : 'false'; + document.getElementById('edit-cfi-real-email').value = service.config?.real_email || ''; + document.getElementById('edit-cfi-password').value = ''; + document.getElementById('edit-cfi-password').placeholder = service.config?.password ? '已设置,留空保持不变' : '请输入密码/授权码'; + const domains = service.config?.domains || []; + const domainsStr = Array.isArray(domains) ? domains.join(',') : String(domains || ''); + document.getElementById('edit-cfi-domains').value = domainsStr; } else { document.getElementById('edit-imap-host').value = service.config?.host || ''; document.getElementById('edit-imap-port').value = service.config?.port || 993; @@ -764,6 +795,18 @@ async function handleEditCustom(e) { } const pwd = formData.get('cm_admin_password'); if (pwd && pwd.trim()) config.admin_password = pwd.trim(); + } else if (subType === 'cloudflare_forward_imap') { + const domainsInput = formData.get('cfi_domains'); + const domains = domainsInput ? domainsInput.split(',').map(d => d.trim()).filter(d => d) : []; + config = { + host: formData.get('cfi_host'), + port: parseInt(formData.get('cfi_port'), 10) || 993, + use_ssl: formData.get('cfi_use_ssl') !== 'false', + real_email: formData.get('cfi_real_email'), + domains: domains + }; + const pwd = formData.get('cfi_password'); + if (pwd && pwd.trim()) config.password = pwd.trim(); } else { config = { host: formData.get('imap_host'), diff --git a/static/js/utils.js b/static/js/utils.js index b7b5dab0..7797f128 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -355,7 +355,8 @@ const statusMap = { temp_mail: 'Temp-Mail(自部署)', duck_mail: 'DuckMail', freemail: 'Freemail', - imap_mail: 'IMAP 邮箱' + imap_mail: 'IMAP 邮箱', + cloudflare_forward_imap: 'Cloudflare 转发 IMAP' } }; diff --git a/templates/email_services.html b/templates/email_services.html index 60a0f04e..32dbb4c9 100644 --- a/templates/email_services.html +++ b/templates/email_services.html @@ -213,6 +213,7 @@

➕ 添加自定义邮箱服务

+
@@ -333,6 +334,39 @@

➕ 添加自定义邮箱服务

例如填写 "test",生成的邮箱为 xxx@test.example.com
+ +
@@ -497,6 +531,40 @@

✏️ 编辑自定义邮箱服务

例如填写 "test",生成的邮箱为 xxx@test.example.com
+ +
From 87d14f0efb1049e5f53de8c3d476224b136a4d51 Mon Sep 17 00:00:00 2001 From: loong Date: Sat, 4 Apr 2026 01:28:53 +0800 Subject: [PATCH 6/6] update --- src/services/cloudflare_forward_imap.py | 5 ++++- src/services/imap_mail.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/services/cloudflare_forward_imap.py b/src/services/cloudflare_forward_imap.py index c059f4d8..f037ff26 100644 --- a/src/services/cloudflare_forward_imap.py +++ b/src/services/cloudflare_forward_imap.py @@ -224,6 +224,9 @@ def get_verification_code( pattern: str = None, otp_sent_at: Optional[float] = None, ) -> Optional[str]: + # 避免参数名遮蔽 email 模块 + import email as email_module + start_time = time.time() seen_ids: set = set() mail = None @@ -252,7 +255,7 @@ def get_verification_code( continue raw = msg_data[0][1] - msg = email.message_from_bytes(raw) + msg = email_module.message_from_bytes(raw) if not self._match_target_email(msg, email): continue diff --git a/src/services/imap_mail.py b/src/services/imap_mail.py index 01573f64..96cac3cc 100644 --- a/src/services/imap_mail.py +++ b/src/services/imap_mail.py @@ -123,6 +123,9 @@ def get_verification_code( otp_sent_at: Optional[float] = None, ) -> Optional[str]: """轮询 IMAP 收件箱,获取 OpenAI 验证码""" + # 避免参数名遮蔽 email 模块 + import email as email_module + start_time = time.time() seen_ids: set = set() mail = None @@ -152,7 +155,7 @@ def get_verification_code( continue raw = msg_data[0][1] - msg = email.message_from_bytes(raw) + msg = email_module.message_from_bytes(raw) # 检查发件人 from_addr = self._decode_str(msg.get("From", ""))