From 14706b37378034c12f0d4fa33b819cf3d77ecdee Mon Sep 17 00:00:00 2001 From: Ukumbuko Date: Mon, 23 Mar 2026 00:03:20 +0800 Subject: [PATCH 1/3] =?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/3] =?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 bee9f11499ef5a4db47053111ed9f4b0a3888bb1 Mon Sep 17 00:00:00 2001 From: jiwangyihao Date: Thu, 2 Apr 2026 04:44:06 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(outlook):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=88=AB=E5=90=8D=E6=89=B9=E9=87=8F=E6=B3=A8=E5=86=8C=E4=B8=8E?= =?UTF-8?q?=20OAuth=20=E9=85=8D=E7=BD=AE=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 Outlook 账户增加别名导入、显示和批量任务展开能力,避免多个别名共用收件箱时串用验证码。 同时补充 OAuth 授权配置、修复订阅状态 free 展示,并让批量注册按未注册别名精确计数与执行。 --- src/database/models.py | 4 +- src/services/outlook/account.py | 13 +- src/services/outlook/email_parser.py | 13 +- src/services/outlook/service.py | 44 ++++- src/services/outlook/token_manager.py | 6 +- src/web/routes/accounts.py | 4 + src/web/routes/email.py | 225 +++++++++++++++++++++++++- src/web/routes/payment.py | 7 +- src/web/routes/registration.py | 124 +++++++++----- static/js/accounts.js | 2 +- static/js/app.js | 15 +- static/js/email_services.js | 130 +++++++++++++-- templates/email_services.html | 35 +++- 13 files changed, 542 insertions(+), 80 deletions(-) diff --git a/src/database/models.py b/src/database/models.py index f662917c..1dd0b409 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -53,7 +53,7 @@ class Account(Base): cpa_uploaded = Column(Boolean, default=False) # 是否已上传到 CPA cpa_uploaded_at = Column(DateTime) # 上传时间 source = Column(String(20), default='register') # 'register' 或 'login',区分账号来源 - subscription_type = Column(String(20)) # None / 'plus' / 'team' + subscription_type = Column(String(20)) # 'free' / 'plus' / 'team' subscription_at = Column(DateTime) # 订阅开通时间 cookies = Column(Text) # 完整 cookie 字符串,用于支付请求 created_at = Column(DateTime, default=datetime.utcnow) @@ -226,4 +226,4 @@ def proxy_url(self) -> str: if self.username and self.password: auth = f"{self.username}:{self.password}@" - return f"{scheme}://{auth}{self.host}:{self.port}" \ No newline at end of file + return f"{scheme}://{auth}{self.host}:{self.port}" diff --git a/src/services/outlook/account.py b/src/services/outlook/account.py index 6f427d59..25ccbdd6 100644 --- a/src/services/outlook/account.py +++ b/src/services/outlook/account.py @@ -2,8 +2,8 @@ Outlook 账户数据类 """ -from dataclasses import dataclass -from typing import Dict, Any, Optional +from dataclasses import dataclass, field +from typing import Dict, Any, Optional, List @dataclass @@ -13,6 +13,7 @@ class OutlookAccount: password: str = "" client_id: str = "" refresh_token: str = "" + aliases: List[str] = field(default_factory=list) @classmethod def from_config(cls, config: Dict[str, Any]) -> "OutlookAccount": @@ -21,7 +22,8 @@ def from_config(cls, config: Dict[str, Any]) -> "OutlookAccount": email=config.get("email", ""), password=config.get("password", ""), client_id=config.get("client_id", ""), - refresh_token=config.get("refresh_token", "") + refresh_token=config.get("refresh_token", ""), + aliases=config.get("aliases", []) ) def has_oauth(self) -> bool: @@ -37,6 +39,8 @@ def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]: result = { "email": self.email, "has_oauth": self.has_oauth(), + "aliases": self.aliases, + "alias_count": len(self.aliases), } if include_sensitive: result.update({ @@ -48,4 +52,5 @@ def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]: def __str__(self) -> str: """字符串表示""" - return f"OutlookAccount({self.email})" + alias_info = f" (+{len(self.aliases)} aliases)" if self.aliases else "" + return f"OutlookAccount({self.email}{alias_info})" diff --git a/src/services/outlook/email_parser.py b/src/services/outlook/email_parser.py index 84d52288..a90a1ee9 100644 --- a/src/services/outlook/email_parser.py +++ b/src/services/outlook/email_parser.py @@ -60,7 +60,15 @@ def is_openai_verification_email( logger.debug(f"邮件未包含验证关键词: {subject[:50]}") return False - # 3. 收件人检查已移除:别名邮件的 IMAP 头中收件人可能不匹配,只靠发件人+关键词判断 + # 3. 优先校验收件人,避免同一收件箱并发注册时串用别名验证码。 + # 若邮件头里没有可用收件人信息,再降级为仅靠发件人+关键词判断。 + if target_email and email.recipients: + target = target_email.lower() + recipient_text = " ".join(email.recipients).lower() + if target not in recipient_text: + logger.debug(f"邮件收件人不匹配目标邮箱: {target_email} not in {recipient_text[:120]}") + return False + logger.debug(f"识别为 OpenAI 验证邮件: {subject[:50]}") return True @@ -148,11 +156,12 @@ def find_verification_code_in_emails( # 时间戳过滤 if min_timestamp > 0 and email.received_timestamp > 0: if email.received_timestamp < min_timestamp: - logger.debug(f"跳过旧邮件: {email.subject[:50]}") + logger.debug(f"跳过旧邮件: {email.subject[:50]} (ts={email.received_timestamp} < min={min_timestamp})") continue # 检查是否是 OpenAI 验证邮件 if not self.is_openai_verification_email(email, target_email): + logger.debug(f"邮件未通过验证检查: {email.subject[:80]} from {email.sender}") continue # 提取验证码 diff --git a/src/services/outlook/service.py b/src/services/outlook/service.py index 321d8b3b..b1f3e715 100644 --- a/src/services/outlook/service.py +++ b/src/services/outlook/service.py @@ -261,7 +261,9 @@ def _try_providers_for_emails( def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: """ - 选择可用的 Outlook 账户 + 选择可用的 Outlook 账户(支持别名轮询) + + 如果有别名,从别名列表中选择一个;否则使用主邮箱地址。 Args: config: 配置参数(未使用) @@ -273,21 +275,40 @@ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: self.update_status(False, EmailServiceError("没有可用的 Outlook 账户")) raise EmailServiceError("没有可用的 Outlook 账户") + runtime_config = config or self.config or {} + # 轮询选择账户 with self._account_lock: account = self.accounts[self._current_account_index] self._current_account_index = (self._current_account_index + 1) % len(self.accounts) + forced_email = runtime_config.get("selected_email") + if forced_email: + selected_email = forced_email + logger.info(f"选择 Outlook 账户: {account.email},使用指定邮箱: {selected_email}") + # 如果有别名,从别名中选择一个(轮询) + elif account.aliases: + # 使用账户级别的别名轮询索引 + if not hasattr(account, '_alias_index'): + account._alias_index = 0 + alias = account.aliases[account._alias_index % len(account.aliases)] + account._alias_index = (account._alias_index + 1) % len(account.aliases) + selected_email = alias + logger.info(f"选择 Outlook 账户: {account.email},使用别名: {selected_email}") + else: + selected_email = account.email + logger.info(f"选择 Outlook 账户: {account.email}") + email_info = { - "email": account.email, + "email": selected_email, "service_id": account.email, "account": { "email": account.email, - "has_oauth": account.has_oauth() + "has_oauth": account.has_oauth(), + "aliases": account.aliases, } } - logger.info(f"选择 Outlook 账户: {account.email}") self.update_status(True) return email_info @@ -312,17 +333,24 @@ def get_verification_code( Returns: 验证码字符串 """ - # 查找对应的账户 + # 查找对应的账户(通过主邮箱或别名匹配) account = None for acc in self.accounts: if acc.email.lower() == email.lower(): account = acc break + # 检查是否在别名列表中 + if any(alias.lower() == email.lower() for alias in acc.aliases): + account = acc + break if not account: self.update_status(False, EmailServiceError(f"未找到邮箱对应的账户: {email}")) + logger.error(f"[{email}] 未找到匹配的账户。当前已配置账户: {[acc.email for acc in self.accounts]}") return None + logger.info(f"[{email}] 匹配到主账户: {account.email} (别名数: {len(account.aliases)})") + # 获取验证码等待配置 code_settings = get_email_code_settings() actual_timeout = timeout or code_settings["timeout"] @@ -359,7 +387,7 @@ def get_verification_code( ) if emails: - logger.debug( + logger.info( f"[{email}] 第 {poll_count} 次轮询获取到 {len(emails)} 封邮件" ) @@ -383,12 +411,14 @@ def get_verification_code( except Exception as e: logger.warning(f"[{email}] 检查出错: {e}") + import traceback + logger.debug(traceback.format_exc()) # 等待下次轮询 time.sleep(poll_interval) elapsed = int(time.time() - start_time) - logger.warning(f"[{email}] 验证码超时 ({actual_timeout}s),共轮询 {poll_count} 次") + logger.warning(f"[{email}] 验证码超时 ({actual_timeout}s),共轮询 {poll_count} 次,未找到验证码") return None def list_emails(self, **kwargs) -> List[Dict[str, Any]]: diff --git a/src/services/outlook/token_manager.py b/src/services/outlook/token_manager.py index 77e54f20..a872453b 100644 --- a/src/services/outlook/token_manager.py +++ b/src/services/outlook/token_manager.py @@ -20,15 +20,15 @@ # 各提供者的 Scope 配置 PROVIDER_SCOPES = { - ProviderType.IMAP_OLD: "", # 旧版 IMAP 不需要特定 scope + ProviderType.IMAP_OLD: "https://outlook.office.com/IMAP.AccessAsUser.All offline_access", ProviderType.IMAP_NEW: "https://outlook.office.com/IMAP.AccessAsUser.All offline_access", ProviderType.GRAPH_API: "https://graph.microsoft.com/.default", } # 各提供者的 Token 端点 PROVIDER_TOKEN_URLS = { - ProviderType.IMAP_OLD: TokenEndpoint.LIVE.value, - ProviderType.IMAP_NEW: TokenEndpoint.CONSUMERS.value, + ProviderType.IMAP_OLD: TokenEndpoint.COMMON.value, + ProviderType.IMAP_NEW: TokenEndpoint.COMMON.value, ProviderType.GRAPH_API: TokenEndpoint.COMMON.value, } diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index a6a597fc..0ce67caf 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -61,6 +61,8 @@ class AccountResponse(BaseModel): proxy_used: Optional[str] = None cpa_uploaded: bool = False cpa_uploaded_at: Optional[str] = None + subscription_type: Optional[str] = None + subscription_at: Optional[str] = None cookies: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None @@ -140,6 +142,8 @@ def account_to_response(account: Account) -> AccountResponse: proxy_used=account.proxy_used, cpa_uploaded=account.cpa_uploaded or False, cpa_uploaded_at=account.cpa_uploaded_at.isoformat() if account.cpa_uploaded_at else None, + subscription_type=account.subscription_type, + subscription_at=account.subscription_at.isoformat() if account.subscription_at else None, cookies=account.cookies, created_at=account.created_at.isoformat() if account.created_at else None, updated_at=account.updated_at.isoformat() if account.updated_at else None, diff --git a/src/web/routes/email.py b/src/web/routes/email.py index 5f0123cf..e49c57f0 100644 --- a/src/web/routes/email.py +++ b/src/web/routes/email.py @@ -3,9 +3,13 @@ """ import logging +import secrets +import time from typing import List, Optional, Dict, Any +from urllib.parse import quote -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import RedirectResponse from pydantic import BaseModel from ...database import crud @@ -16,6 +20,9 @@ logger = logging.getLogger(__name__) router = APIRouter() +# OAuth2 状态存储 (内存,进程重启后失效) +_oauth_states: Dict[str, Dict[str, Any]] = {} + # ============== Pydantic Models ============== @@ -72,6 +79,11 @@ class OutlookBatchImportRequest(BaseModel): priority: int = 0 +class OutlookAliasesImportRequest(BaseModel): + """Outlook 别名导入请求""" + aliases: List[str] + + class OutlookBatchImportResponse(BaseModel): """Outlook 批量导入响应""" total: int @@ -103,6 +115,10 @@ def filter_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]: if config.get('client_id') and config.get('refresh_token'): filtered['has_oauth'] = True + # 为 Outlook 添加别名数量 + if 'aliases' in config: + filtered['alias_count'] = len(config['aliases']) + return filtered @@ -559,6 +575,37 @@ async def batch_import_outlook(request: OutlookBatchImportRequest): ) +@router.post("/outlook/{service_id}/aliases") +async def import_outlook_aliases(service_id: int, request: OutlookAliasesImportRequest): + """ + 为指定 Outlook 账户添加别名列表 + + 别名用于注册时轮询使用,所有别名邮件都会进入主邮箱收件箱。 + """ + with get_db() as db: + service = db.query(EmailServiceModel).filter( + EmailServiceModel.id == service_id, + EmailServiceModel.service_type == "outlook" + ).first() + if not service: + raise HTTPException(status_code=404, detail="Outlook 账户不存在") + + config = service.config or {} + config["aliases"] = request.aliases + from sqlalchemy.orm.attributes import flag_modified + service.config = config + flag_modified(service, "config") + db.commit() + db.refresh(service) + + return { + "success": True, + "service_id": service_id, + "alias_count": len(request.aliases), + "message": f"已添加 {len(request.aliases)} 个别名" + } + + @router.delete("/outlook/batch") async def batch_delete_outlook(service_ids: List[int]): """批量删除 Outlook 邮箱服务""" @@ -608,3 +655,179 @@ async def test_tempmail_service(request: TempmailTestRequest): except Exception as e: logger.error(f"测试临时邮箱失败: {e}") return {"success": False, "message": f"测试失败: {str(e)}"} + + +# ============== Outlook OAuth2 授权 ============== + +# 保留上游默认 client_id;若账户里已配置 client_id,则优先使用账户配置 +DEFAULT_OUTLOOK_CLIENT_ID = "24d9a0ed-8787-4584-883c-2fd79308940a" + +# IMAP 所需 scope +IMAP_SCOPE = "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access" + + +@router.get("/outlook/oauth/authorize") +async def outlook_oauth_authorize( + request: Request, + service_id: Optional[int] = Query(None, description="关联的邮箱服务 ID(可选)"), + client_id: Optional[str] = Query(None, description="自定义 client_id(可选,默认使用账户配置)"), + admin_consent: bool = Query(False, description="是否使用管理员同意流程"), +): + """ + 发起 Outlook OAuth2 授权流程 + + 跳转到微软登录页面,用户授权后回调获取 refresh_token。 + 管理员可通过 admin_consent=true 代表整个组织授权。 + """ + actual_client_id = "" + if service_id: + with get_db() as db: + service = db.query(EmailServiceModel).filter( + EmailServiceModel.id == service_id, + EmailServiceModel.service_type == "outlook" + ).first() + if service and service.config: + actual_client_id = (service.config.get("client_id") or "").strip() + + if not actual_client_id: + actual_client_id = (client_id or DEFAULT_OUTLOOK_CLIENT_ID).strip() + + if not actual_client_id: + raise HTTPException(status_code=400, detail="请先在 Outlook 账户配置中填写 OAuth Client ID") + + # 生成 state 用于防 CSRF 和传递 service_id + state_token = secrets.token_urlsafe(32) + _oauth_states[state_token] = { + "service_id": service_id, + "client_id": actual_client_id, + "created_at": time.time(), + } + + # 清理过期 state(超过 10 分钟) + now = time.time() + expired = [k for k, v in _oauth_states.items() if now - v["created_at"] > 600] + for k in expired: + del _oauth_states[k] + + redirect_uri = str(request.url_for("outlook_oauth_callback")) + + if admin_consent: + # 管理员同意端点:代表整个组织授权 + auth_url = ( + f"https://login.microsoftonline.com/organizations/v2.0/adminconsent?" + f"client_id={actual_client_id}" + f"&redirect_uri={quote(redirect_uri, safe='')}" + f"&state={state_token}" + f"&scope={quote(IMAP_SCOPE, safe='')}" + ) + else: + # 普通用户授权 + auth_url = ( + f"https://login.microsoftonline.com/common/oauth2/v2.0/authorize?" + f"client_id={actual_client_id}" + f"&response_type=code" + f"&redirect_uri={quote(redirect_uri, safe='')}" + f"&response_mode=query" + f"&scope={quote(IMAP_SCOPE, safe='')}" + f"&state={state_token}" + f"&prompt=select_account" + ) + + return RedirectResponse(url=auth_url) + + +@router.get("/outlook/oauth/callback") +async def outlook_oauth_callback( + request: Request, + code: Optional[str] = Query(None), + state: Optional[str] = Query(None), + error: Optional[str] = Query(None), +): + """ + OAuth2 授权回调 + + 获取 authorization code,然后交换 refresh_token。 + """ + from curl_cffi import requests as _requests + + # 检查错误 + if error: + error_desc = request.query_params.get("error_description", error) + return { + "success": False, + "message": f"授权失败: {error}", + "details": error_desc, + } + + if not code or not state: + return {"success": False, "message": "缺少授权码或 state"} + + # 验证 state + state_data = _oauth_states.pop(state, None) + if not state_data: + return {"success": False, "message": "授权已过期或无效,请重新授权"} + + service_id = state_data.get("service_id") + client_id = state_data.get("client_id", DEFAULT_OUTLOOK_CLIENT_ID) + + # 用 authorization code 交换 refresh_token + token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" + redirect_uri = str(request.url_for("outlook_oauth_callback")) + + try: + resp = _requests.post( + token_url, + data={ + "client_id": client_id, + "code": code, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + "scope": IMAP_SCOPE, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30, + impersonate="chrome110", + ) + + if resp.status_code != 200: + return { + "success": False, + "message": f"Token 交换失败: HTTP {resp.status_code}", + "details": resp.text[:500], + } + + token_data = resp.json() + refresh_token = token_data.get("refresh_token") + + if not refresh_token: + return {"success": False, "message": "未获取到 refresh_token"} + + # 如果有 service_id,直接更新到数据库 + if service_id: + with get_db() as db: + service = db.query(EmailServiceModel).filter( + EmailServiceModel.id == service_id, + EmailServiceModel.service_type == "outlook" + ).first() + if service: + config = service.config or {} + config["client_id"] = client_id + config["refresh_token"] = refresh_token + from sqlalchemy.orm.attributes import flag_modified + service.config = config + flag_modified(service, "config") + db.commit() + logger.info(f"已为 Outlook 服务 {service_id} 更新 OAuth 凭据") + + # 返回成功结果,不暴露 refresh_token 内容 + return { + "success": True, + "message": "OAuth 授权成功!", + "client_id": client_id, + "service_id": service_id, + "hint": "refresh_token 已自动保存到对应的邮箱服务配置中(如果指定了 service_id)", + } + + except Exception as e: + logger.error(f"OAuth 回调处理失败: {e}") + return {"success": False, "message": f"处理失败: {str(e)}"} diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py index ef9ff6b3..0d85faec 100644 --- a/src/web/routes/payment.py +++ b/src/web/routes/payment.py @@ -145,8 +145,8 @@ def batch_check_subscription(request: BatchCheckSubscriptionRequest): try: status = check_subscription_status(account, proxy) - account.subscription_type = None if status == "free" else status - account.subscription_at = datetime.utcnow() if status != "free" else account.subscription_at + account.subscription_type = status + account.subscription_at = datetime.utcnow() if status != "free" else None db.commit() results["success_count"] += 1 results["details"].append( @@ -173,10 +173,9 @@ def mark_subscription(account_id: int, request: MarkSubscriptionRequest): if not account: raise HTTPException(status_code=404, detail="账号不存在") - account.subscription_type = None if request.subscription_type == "free" else request.subscription_type + account.subscription_type = request.subscription_type account.subscription_at = datetime.utcnow() if request.subscription_type != "free" else None db.commit() return {"success": True, "subscription_type": request.subscription_type} - diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index 0200dd57..d15a411b 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -27,6 +27,8 @@ running_tasks: dict = {} # 批量任务存储 batch_tasks: Dict[str, dict] = {} +# Outlook 批量任务目标邮箱覆盖(task_uuid -> selected_email) +outlook_task_targets: Dict[str, str] = {} # ============== Proxy Helper Functions ============== @@ -139,6 +141,7 @@ class OutlookAccountForRegistration(BaseModel): has_oauth: bool # 是否有 OAuth 配置 is_registered: bool # 是否已注册 registered_account_id: Optional[int] = None + alias_count: int = 0 # 别名数量 class OutlookAccountsListResponse(BaseModel): @@ -277,6 +280,9 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: if db_service: service_type = EmailServiceType(db_service.service_type) config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url) + selected_email = outlook_task_targets.get(task_uuid) + if selected_email and service_type == EmailServiceType.OUTLOOK: + config = {**config, "selected_email": selected_email} # 更新任务关联的邮箱服务 crud.update_registration_task(db, task_uuid, email_service_id=db_service.id) logger.info(f"使用数据库邮箱服务: {db_service.name} (ID: {db_service.id}, 类型: {service_type.value})") @@ -323,23 +329,35 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: if not outlook_services: raise ValueError("没有可用的 Outlook 账户,请先在设置中导入账户") - # 找到一个未注册的 Outlook 账户 + # 找到一个未注册的 Outlook 账户(检查主邮箱和别名) selected_service = None for svc in outlook_services: email = svc.config.get("email") if svc.config else None if not email: continue - # 检查是否已在 accounts 表中注册 + # 检查主邮箱是否已注册 existing = db.query(Account).filter(Account.email == email).first() - if not existing: - selected_service = svc - logger.info(f"选择未注册的 Outlook 账户: {email}") - break - else: + if existing: logger.info(f"跳过已注册的 Outlook 账户: {email}") + continue + # 检查别名是否已注册 + aliases = svc.config.get("aliases", []) if svc.config else [] + registered_aliases = [] + for alias in aliases: + alias_exists = db.query(Account).filter(Account.email == alias).first() + if alias_exists: + registered_aliases.append(alias) + if registered_aliases: + logger.info(f"Outlook 账户 {email} 的部分别名已注册: {registered_aliases}") + selected_service = svc + logger.info(f"选择未注册的 Outlook 账户: {email}(剩余别名: {len(aliases) - len(registered_aliases)})") + break if selected_service and selected_service.config: config = selected_service.config.copy() + selected_email = outlook_task_targets.get(task_uuid) + if selected_email: + config["selected_email"] = selected_email crud.update_registration_task(db, task_uuid, email_service_id=selected_service.id) logger.info(f"使用数据库 Outlook 账户: {selected_service.name}") else: @@ -536,6 +554,8 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: task_manager.update_status(task_uuid, "failed", error=str(e)) except: pass + finally: + outlook_task_targets.pop(task_uuid, None) async def run_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_ids: List[int] = None, auto_upload_sub2api: bool = False, sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None): @@ -1380,13 +1400,16 @@ async def get_outlook_accounts_for_registration(): else: unregistered_count += 1 + alias_count = len(config.get("aliases", [])) + accounts.append(OutlookAccountForRegistration( id=service.id, email=email, name=service.name, has_oauth=bool(config.get("client_id") and config.get("refresh_token")), is_registered=is_registered, - registered_account_id=existing_account.id if existing_account else None + registered_account_id=existing_account.id if existing_account else None, + alias_count=alias_count )) return OutlookAccountsListResponse( @@ -1399,7 +1422,7 @@ async def get_outlook_accounts_for_registration(): async def run_outlook_batch_registration( batch_id: str, - service_ids: List[int], + task_specs: List[Dict[str, object]], skip_registered: bool, proxy: Optional[str], interval_min: int, @@ -1416,7 +1439,7 @@ async def run_outlook_batch_registration( """ 异步执行 Outlook 批量注册任务,复用通用并发逻辑 - 将每个 service_id 映射为一个独立的 task_uuid,然后调用 + 将每个 Outlook 主账号/别名目标映射为一个独立的 task_uuid,然后调用 run_batch_registration 的并发逻辑 """ loop = task_manager.get_loop() @@ -1424,10 +1447,12 @@ async def run_outlook_batch_registration( loop = asyncio.get_event_loop() task_manager.set_loop(loop) - # 预先为每个 service_id 创建注册任务记录 + # 预先为每个目标邮箱创建独立注册任务 task_uuids = [] with get_db() as db: - for service_id in service_ids: + for spec in task_specs: + service_id = int(spec["service_id"]) + selected_email = str(spec["selected_email"]) task_uuid = str(uuid.uuid4()) crud.create_registration_task( db, @@ -1436,15 +1461,21 @@ async def run_outlook_batch_registration( email_service_id=service_id ) task_uuids.append(task_uuid) + outlook_task_targets[task_uuid] = selected_email + + if not task_uuids: + batch_tasks[batch_id]["finished"] = True + batch_tasks[batch_id]["cancelled"] = False + return - # 复用通用并发逻辑(outlook 服务类型,每个任务通过 email_service_id 定位账户) + # 复用通用并发逻辑 await run_batch_registration( batch_id=batch_id, task_uuids=task_uuids, email_service_type="outlook", proxy=proxy, email_service_config=None, - email_service_id=None, # 每个任务已绑定了独立的 email_service_id + email_service_id=None, interval_min=interval_min, interval_max=interval_max, concurrency=concurrency, @@ -1488,38 +1519,49 @@ async def start_outlook_batch_registration( if request.mode not in ("parallel", "pipeline"): raise HTTPException(status_code=400, detail="模式必须为 parallel 或 pipeline") - # 过滤掉已注册的邮箱 - actual_service_ids = request.service_ids + task_specs: List[Dict[str, object]] = [] skipped_count = 0 + actual_service_ids: List[int] = [] - if request.skip_registered: - actual_service_ids = [] - with get_db() as db: - for service_id in request.service_ids: - service = db.query(EmailServiceModel).filter( - EmailServiceModel.id == service_id - ).first() + with get_db() as db: + for service_id in request.service_ids: + service = db.query(EmailServiceModel).filter( + EmailServiceModel.id == service_id + ).first() + if not service: + continue - if not service: + config = service.config or {} + aliases = config.get("aliases", []) + email = config.get("email") or service.name + added_for_service = False + + if aliases: + for alias in aliases: + alias_exists = db.query(Account).filter(Account.email == alias).first() + if request.skip_registered and alias_exists: + skipped_count += 1 + continue + task_specs.append({"service_id": service_id, "selected_email": alias}) + added_for_service = True + else: + main_exists = db.query(Account).filter(Account.email == email).first() + if request.skip_registered and main_exists: + skipped_count += 1 continue + task_specs.append({"service_id": service_id, "selected_email": email}) + added_for_service = True - config = service.config or {} - email = config.get("email") or service.name + if added_for_service and service_id not in actual_service_ids: + actual_service_ids.append(service_id) - # 检查是否已注册 - existing_account = db.query(Account).filter( - Account.email == email - ).first() - - if existing_account: - skipped_count += 1 - else: - actual_service_ids.append(service_id) + total_selected = len(task_specs) + skipped_count + total_to_register = len(task_specs) - if not actual_service_ids: + if total_to_register == 0: return OutlookBatchRegistrationResponse( batch_id="", - total=len(request.service_ids), + total=total_selected, skipped=skipped_count, to_register=0, service_ids=[] @@ -1530,7 +1572,7 @@ async def start_outlook_batch_registration( # 初始化批量任务状态 batch_tasks[batch_id] = { - "total": len(actual_service_ids), + "total": total_to_register, "completed": 0, "success": 0, "failed": 0, @@ -1546,7 +1588,7 @@ async def start_outlook_batch_registration( background_tasks.add_task( run_outlook_batch_registration, batch_id, - actual_service_ids, + task_specs, request.skip_registered, request.proxy, request.interval_min, @@ -1563,9 +1605,9 @@ async def start_outlook_batch_registration( return OutlookBatchRegistrationResponse( batch_id=batch_id, - total=len(request.service_ids), + total=total_selected, skipped=skipped_count, - to_register=len(actual_service_ids), + to_register=total_to_register, service_ids=actual_service_ids ) diff --git a/static/js/accounts.js b/static/js/accounts.js index fe9848c3..9dd0d606 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -315,7 +315,7 @@ function renderAccounts(accounts) {
${account.subscription_type - ? `${account.subscription_type}` + ? `${account.subscription_type}` : `-`}
diff --git a/static/js/app.js b/static/js/app.js index 8bf5d5e4..5eb5ba54 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1128,11 +1128,16 @@ function renderOutlookAccountsList() { return; } - const html = outlookAccounts.map(account => ` + const html = outlookAccounts.map(account => { + const aliasCount = account.alias_count || 0; + const aliasBadge = aliasCount > 0 + ? `${aliasCount}别名` + : ''; + return ` - `).join(''); + `; + }).join(''); elements.outlookAccountsContainer.innerHTML = html; } @@ -1216,7 +1221,7 @@ async function handleOutlookBatchRegistration() { tm_service_ids: elements.autoUploadTm && elements.autoUploadTm.checked ? getSelectedServiceIds(elements.tmServiceSelect) : [], }; - addLog('info', `[系统] 正在启动 Outlook 批量注册 (${selectedIds.length} 个账户)...`); + addLog('info', `[系统] 正在启动 Outlook 批量注册...`); try { const data = await api.post('/registration/outlook-batch', requestData); diff --git a/static/js/email_services.js b/static/js/email_services.js index a6742c27..3fdb196c 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -26,6 +26,15 @@ const elements = { clearImportBtn: document.getElementById('clear-import-btn'), importResult: document.getElementById('import-result'), + // 别名导入 + toggleAliasImport: document.getElementById('toggle-alias-import'), + aliasImportBody: document.getElementById('alias-import-body'), + aliasServiceSelect: document.getElementById('alias-service-select'), + aliasImportData: document.getElementById('alias-import-data'), + aliasImportBtn: document.getElementById('alias-import-btn'), + clearAliasBtn: document.getElementById('clear-alias-btn'), + aliasImportResult: document.getElementById('alias-import-result'), + // Outlook 列表 outlookTable: document.getElementById('outlook-accounts-table'), selectAllOutlook: document.getElementById('select-all-outlook'), @@ -103,6 +112,14 @@ function initEventListeners() { elements.toggleOutlookImport.textContent = isHidden ? '收起' : '展开'; }); + // 别名导入展开/收起 + elements.toggleAliasImport.addEventListener('click', () => { + const isHidden = elements.aliasImportBody.style.display === 'none'; + elements.aliasImportBody.style.display = isHidden ? 'block' : 'none'; + elements.toggleAliasImport.textContent = isHidden ? '收起' : '展开'; + if (isHidden) populateAliasServiceSelect(); + }); + // Outlook 导入 elements.outlookImportBtn.addEventListener('click', handleOutlookImport); elements.clearImportBtn.addEventListener('click', () => { @@ -110,6 +127,13 @@ function initEventListeners() { elements.importResult.style.display = 'none'; }); + // 别名导入 + elements.aliasImportBtn.addEventListener('click', handleAliasImport); + elements.clearAliasBtn.addEventListener('click', () => { + elements.aliasImportData.value = ''; + elements.aliasImportResult.style.display = 'none'; + }); + // Outlook 全选 elements.selectAllOutlook.addEventListener('change', (e) => { const checkboxes = elements.outlookTable.querySelectorAll('input[type="checkbox"][data-id]'); @@ -238,20 +262,29 @@ async function loadOutlookServices() { return; } - elements.outlookTable.innerHTML = outlookServices.map(service => ` + elements.outlookTable.innerHTML = outlookServices.map(service => { + const aliasCount = service.config?.alias_count || service.config?.aliases?.length || 0; + const aliasBadge = aliasCount > 0 + ? `${aliasCount}` + : `0`; + const authBadge = service.config?.has_oauth + ? `OAuth` + : `密码`; + const oauthBtn = service.config?.has_oauth + ? `` + : ``; + return ` ${escapeHtml(service.config?.email || service.name)} - - - ${service.config?.has_oauth ? 'OAuth' : '密码'} - - + ${aliasBadge} + ${authBadge} ${service.enabled ? '✅' : '⭕'} ${service.priority} ${format.date(service.last_used)}
+ ${oauthBtn} - - `).join(''); + `; + }).join(''); elements.outlookTable.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => { cb.addEventListener('change', (e) => { @@ -804,6 +837,9 @@ async function editOutlookService(id) { document.getElementById('edit-outlook-client-id').value = service.config?.client_id || ''; document.getElementById('edit-outlook-refresh-token').value = ''; document.getElementById('edit-outlook-refresh-token').placeholder = service.config?.refresh_token ? '已设置,留空保持不变' : 'OAuth Refresh Token'; + // 别名:从 config 中读取,每行一个 + const aliases = service.config?.aliases || []; + document.getElementById('edit-outlook-aliases').value = aliases.join('\n'); document.getElementById('edit-outlook-priority').value = service.priority || 0; document.getElementById('edit-outlook-enabled').checked = service.enabled; elements.editOutlookModal.classList.add('active'); @@ -826,6 +862,10 @@ async function handleEditOutlook(e) { return; } + // 解析别名列表 + const aliasesText = formData.get('aliases') || ''; + const aliases = aliasesText.split('\n').map(a => a.trim()).filter(a => a); + const updateData = { name: formData.get('email'), priority: parseInt(formData.get('priority')) || 0, @@ -834,7 +874,8 @@ async function handleEditOutlook(e) { email: formData.get('email'), password: formData.get('password')?.trim() || currentService.config?.password || '', client_id: formData.get('client_id')?.trim() || currentService.config?.client_id || '', - refresh_token: formData.get('refresh_token')?.trim() || currentService.config?.refresh_token || '' + refresh_token: formData.get('refresh_token')?.trim() || currentService.config?.refresh_token || '', + aliases: aliases, } }; @@ -848,3 +889,74 @@ async function handleEditOutlook(e) { toast.error('更新失败: ' + error.message); } } + +// 填充别名导入的服务选择下拉框 +function populateAliasServiceSelect() { + const select = elements.aliasServiceSelect; + select.innerHTML = outlookServices.map(s => + `` + ).join(''); +} + +// 别名导入 +async function handleAliasImport() { + const serviceId = parseInt(elements.aliasServiceSelect.value); + const data = elements.aliasImportData.value.trim(); + if (!serviceId) { toast.error('请选择一个 Outlook 账户'); return; } + if (!data) { toast.error('请输入别名列表'); return; } + + const aliases = data.split('\n').map(a => a.trim()).filter(a => a); + if (aliases.length === 0) { toast.error('没有有效的别名'); return; } + + elements.aliasImportBtn.disabled = true; + elements.aliasImportBtn.textContent = '导入中...'; + + try { + const result = await api.post(`/email-services/outlook/${serviceId}/aliases`, { + aliases: aliases + }); + + elements.aliasImportResult.style.display = 'block'; + elements.aliasImportResult.innerHTML = ` +
+ ✅ 成功导入: ${result.alias_count || 0} 个别名 +
+ `; + toast.success(`成功导入 ${result.alias_count} 个别名`); + elements.aliasImportData.value = ''; + loadOutlookServices(); + loadStats(); + } catch (error) { + toast.error('别名导入失败: ' + error.message); + } finally { + elements.aliasImportBtn.disabled = false; + elements.aliasImportBtn.textContent = '🏷️ 导入别名'; + } +} + +// Outlook OAuth2 授权 +function authorizeOutlookOAuth(serviceId) { + const authUrl = `/api/email-services/outlook/oauth/authorize?service_id=${serviceId}`; + const width = 500; + const height = 600; + const left = (window.screen.width - width) / 2; + const top = (window.screen.height - height) / 2; + const win = window.open( + authUrl, + 'outlook_oauth', + `width=${width},height=${height},left=${left},top=${top},scrollbars=yes` + ); + + // 轮询检查授权是否完成 + const checkInterval = setInterval(() => { + if (win.closed) { + clearInterval(checkInterval); + loadOutlookServices(); + loadStats(); + } + }, 1000); +} + +function renewOutlookOAuth(serviceId) { + authorizeOutlookOAuth(serviceId); +} diff --git a/templates/email_services.html b/templates/email_services.html index 60a0f04e..c8d0b859 100644 --- a/templates/email_services.html +++ b/templates/email_services.html @@ -93,6 +93,33 @@

📥 Outlook 批量导入

+ +
+
+

🏷️ Outlook 别名导入

+ +
+ +
+
@@ -142,6 +169,7 @@

📧 Outlook 账户列表

邮箱 + 别名数 认证方式 状态 优先级 @@ -151,7 +179,7 @@

📧 Outlook 账户列表

- +
@@ -546,6 +574,11 @@

✏️ 编辑 Outlook 账户

留空则保持原值不变
+
+ + + 别名邮件会自动进入主邮箱收件箱,注册时会轮询使用这些别名 +