diff --git a/src/config/constants.py b/src/config/constants.py index c6be6ae..dcf24ee 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -34,6 +34,7 @@ class EmailServiceType(str, Enum): TEMPMAIL = "tempmail" OUTLOOK = "outlook" CUSTOM_DOMAIN = "custom_domain" + TEMP_MAIL = "temp_mail" # ============================================================================ diff --git a/src/services/__init__.py b/src/services/__init__.py index 144805c..7718a09 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -13,11 +13,13 @@ from .base import ( from .tempmail import TempmailService from .outlook import OutlookService from .custom_domain import CustomDomainEmailService +from .temp_mail import TempMailService # 注册服务 EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService) EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService) EmailServiceFactory.register(EmailServiceType.CUSTOM_DOMAIN, CustomDomainEmailService) +EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService) # 导出 Outlook 模块的额外内容 from .outlook.base import ( @@ -47,6 +49,7 @@ __all__ = [ 'TempmailService', 'OutlookService', 'CustomDomainEmailService', + 'TempMailService', # Outlook 模块 'ProviderType', 'EmailMessage', diff --git a/src/services/temp_mail.py b/src/services/temp_mail.py new file mode 100644 index 0000000..dcd6e33 --- /dev/null +++ b/src/services/temp_mail.py @@ -0,0 +1,268 @@ +""" +Temp-Mail 邮箱服务实现 +基于自部署 Cloudflare Worker 临时邮箱服务 +接口文档参见 plan/temp-mail.md +""" + +import re +import time +import json +import logging +from typing import Optional, Dict, Any + +from .base import BaseEmailService, EmailServiceError, EmailServiceType +from ..core.http_client import HTTPClient, RequestConfig +from ..config.constants import OTP_CODE_PATTERN + + +logger = logging.getLogger(__name__) + + +class TempMailService(BaseEmailService): + """ + Temp-Mail 邮箱服务 + 基于自部署 Cloudflare Worker 的临时邮箱,admin 模式管理邮箱 + 不走代理,不使用 requests 库 + """ + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + """ + 初始化 TempMail 服务 + + Args: + config: 配置字典,支持以下键: + - base_url: Worker 域名地址,如 https://mail.example.com (必需) + - admin_password: Admin 密码,对应 x-admin-auth header (必需) + - domain: 邮箱域名,如 example.com (必需) + - enable_prefix: 是否启用前缀,默认 True + - timeout: 请求超时时间,默认 30 + - max_retries: 最大重试次数,默认 3 + name: 服务名称 + """ + super().__init__(EmailServiceType.TEMP_MAIL, name) + + required_keys = ["base_url", "admin_password", "domain"] + missing_keys = [key for key in required_keys if not (config or {}).get(key)] + if missing_keys: + raise ValueError(f"缺少必需配置: {missing_keys}") + + default_config = { + "enable_prefix": True, + "timeout": 30, + "max_retries": 3, + } + self.config = {**default_config, **(config or {})} + + # 不走代理,proxy_url=None + http_config = RequestConfig( + timeout=self.config["timeout"], + max_retries=self.config["max_retries"], + ) + self.http_client = HTTPClient(proxy_url=None, config=http_config) + + # 邮箱缓存:email -> {jwt, address} + self._email_cache: Dict[str, Dict[str, Any]] = {} + + def _admin_headers(self) -> Dict[str, str]: + """构造 admin 请求头""" + return { + "x-admin-auth": self.config["admin_password"], + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _make_request(self, method: str, path: str, **kwargs) -> Any: + """ + 发送请求并返回 JSON 数据 + + Args: + method: HTTP 方法 + path: 请求路径(以 / 开头) + **kwargs: 传递给 http_client.request 的额外参数 + + Returns: + 响应 JSON 数据 + + Raises: + EmailServiceError: 请求失败 + """ + base_url = self.config["base_url"].rstrip("/") + url = f"{base_url}{path}" + + # 合并默认 admin headers + kwargs.setdefault("headers", {}) + for k, v in self._admin_headers().items(): + kwargs["headers"].setdefault(k, v) + + try: + response = self.http_client.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 json.JSONDecodeError: + return {"raw_response": response.text} + + except Exception as e: + self.update_status(False, e) + if isinstance(e, EmailServiceError): + raise + raise EmailServiceError(f"请求失败: {method} {path} - {e}") + + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + """ + 通过 admin API 创建临时邮箱 + + Returns: + 包含邮箱信息的字典: + - email: 邮箱地址 + - jwt: 用户级 JWT token + - service_id: 同 email(用作标识) + """ + import random + import string + + # 生成随机邮箱名 + letters = ''.join(random.choices(string.ascii_lowercase, k=5)) + digits = ''.join(random.choices(string.digits, k=random.randint(1, 3))) + suffix = ''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3))) + name = letters + digits + suffix + + domain = self.config["domain"] + enable_prefix = self.config.get("enable_prefix", True) + + body = { + "enablePrefix": enable_prefix, + "name": name, + "domain": domain, + } + + try: + response = self._make_request("POST", "/admin/new_address", json=body) + + address = response.get("address", "").strip() + jwt = response.get("jwt", "").strip() + + if not address: + raise EmailServiceError(f"API 返回数据不完整: {response}") + + email_info = { + "email": address, + "jwt": jwt, + "service_id": address, + "id": address, + "created_at": time.time(), + } + + # 缓存 jwt,供获取验证码时使用 + self._email_cache[address] = email_info + + logger.info(f"成功创建 TempMail 邮箱: {address}") + 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]: + """ + 从 TempMail 邮箱获取验证码 + + Args: + email: 邮箱地址 + email_id: 未使用,保留接口兼容 + timeout: 超时时间(秒) + pattern: 验证码正则 + otp_sent_at: OTP 发送时间戳(暂未使用) + + Returns: + 验证码字符串,超时返回 None + """ + logger.info(f"正在从 TempMail 邮箱 {email} 获取验证码...") + + start_time = time.time() + seen_mail_ids: set = set() + + while time.time() - start_time < timeout: + try: + # 使用 admin API 查询邮件,通过 address 参数过滤 + response = self._make_request( + "GET", + "/admin/mails", + params={"limit": 20, "offset": 0, "address": email}, + ) + + # admin/mails 返回格式: {"results": [...], "total": N} + mails = response.get("results", []) + if not isinstance(mails, list): + time.sleep(3) + continue + + for mail in mails: + mail_id = mail.get("id") + if not mail_id or mail_id in seen_mail_ids: + continue + + seen_mail_ids.add(mail_id) + + sender = str(mail.get("source", "")).lower() + subject = str(mail.get("subject", "")) + body_text = str(mail.get("text", "") or mail.get("html", "") or "") + + # 去除简单 HTML 标签 + body_clean = re.sub(r"<[^>]+>", " ", body_text) + + content = f"{sender} {subject} {body_clean}" + + # 只处理 OpenAI 邮件 + if "openai" not in sender and "openai" not in content.lower(): + continue + + match = re.search(pattern, content) + if match: + code = match.group(1) + logger.info(f"从 TempMail 邮箱 {email} 找到验证码: {code}") + self.update_status(True) + return code + + except Exception as e: + logger.debug(f"检查 TempMail 邮件时出错: {e}") + + time.sleep(3) + + logger.warning(f"等待 TempMail 验证码超时: {email}") + return None + + def check_health(self) -> bool: + """检查服务健康状态""" + try: + self._make_request( + "GET", + "/admin/mails", + params={"limit": 1, "offset": 0}, + ) + self.update_status(True) + return True + except Exception as e: + logger.warning(f"TempMail 健康检查失败: {e}") + self.update_status(False, e) + return False diff --git a/src/web/routes/email_services.py b/src/web/routes/email_services.py index a2282d4..b2b4499 100644 --- a/src/web/routes/email_services.py +++ b/src/web/routes/email_services.py @@ -143,6 +143,7 @@ async def get_email_services_stats(): stats = { 'outlook_count': 0, 'custom_count': 0, + 'temp_mail_count': 0, 'tempmail_available': True, # 临时邮箱始终可用 'enabled_count': enabled_count } @@ -152,6 +153,8 @@ async def get_email_services_stats(): stats['outlook_count'] = count elif service_type == 'custom_domain': stats['custom_count'] = count + elif service_type == 'temp_mail': + stats['temp_mail_count'] = count return stats @@ -190,6 +193,17 @@ async def get_service_types(): {"name": "api_key", "label": "API Key", "required": True}, {"name": "default_domain", "label": "默认域名", "required": False}, ] + }, + { + "value": "temp_mail", + "label": "Temp-Mail(自部署)", + "description": "自部署 Cloudflare Worker 临时邮箱,admin 模式管理", + "config_fields": [ + {"name": "base_url", "label": "Worker 地址", "required": True, "placeholder": "https://mail.example.com"}, + {"name": "admin_password", "label": "Admin 密码", "required": True, "secret": True}, + {"name": "domain", "label": "邮箱域名", "required": True, "placeholder": "example.com"}, + {"name": "enable_prefix", "label": "启用前缀", "required": False, "default": True}, + ] } ] } diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index 8f70809..4427086 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -930,6 +930,11 @@ async def get_available_email_services(): "available": False, "count": 0, "services": [] + }, + "temp_mail": { + "available": False, + "count": 0, + "services": [] } } @@ -984,6 +989,25 @@ async def get_available_email_services(): "from_settings": True }) + # 获取 TempMail 服务(自部署 Cloudflare Worker 临时邮箱) + temp_mail_services = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "temp_mail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).all() + + for service in temp_mail_services: + config = service.config or {} + result["temp_mail"]["services"].append({ + "id": service.id, + "name": service.name, + "type": "temp_mail", + "domain": config.get("domain"), + "priority": service.priority + }) + + result["temp_mail"]["count"] = len(temp_mail_services) + result["temp_mail"]["available"] = len(temp_mail_services) > 0 + return result diff --git a/static/js/app.js b/static/js/app.js index 7b44e10..aebdb18 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -21,7 +21,8 @@ let toastShown = false; // 标记是否已显示过 toast let availableServices = { tempmail: { available: true, services: [] }, outlook: { available: false, services: [] }, - custom_domain: { available: false, services: [] } + custom_domain: { available: false, services: [] }, + temp_mail: { available: false, services: [] } }; // WebSocket 相关变量 @@ -248,6 +249,23 @@ function updateEmailServiceOptions() { select.appendChild(optgroup); } + + // Temp-Mail(自部署) + if (availableServices.temp_mail && availableServices.temp_mail.available) { + const optgroup = document.createElement('optgroup'); + optgroup.label = `📮 Temp-Mail 自部署 (${availableServices.temp_mail.count} 个服务)`; + + availableServices.temp_mail.services.forEach(service => { + const option = document.createElement('option'); + option.value = `temp_mail:${service.id}`; + option.textContent = service.name + (service.domain ? ` (@${service.domain})` : ''); + option.dataset.type = 'temp_mail'; + 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 4ffd2dc..0e05476 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -58,7 +58,19 @@ const elements = { editOutlookModal: document.getElementById('edit-outlook-modal'), editOutlookForm: document.getElementById('edit-outlook-form'), closeEditOutlookModal: document.getElementById('close-edit-outlook-modal'), - cancelEditOutlook: document.getElementById('cancel-edit-outlook') + cancelEditOutlook: document.getElementById('cancel-edit-outlook'), + + // Temp-Mail 服务 + tempMailTable: document.getElementById('tempmail-services-table'), + addTempMailBtn: document.getElementById('add-tempmail-btn'), + addTempMailModal: document.getElementById('add-tempmail-modal'), + addTempMailForm: document.getElementById('add-tempmail-form'), + closeAddTempMailModal: document.getElementById('close-add-tempmail-modal'), + cancelAddTempMail: document.getElementById('cancel-add-tempmail'), + editTempMailModal: document.getElementById('edit-tempmail-modal'), + editTempMailForm: document.getElementById('edit-tempmail-form'), + closeEditTempMailModal: document.getElementById('close-edit-tempmail-modal'), + cancelEditTempMail: document.getElementById('cancel-edit-tempmail') }; // 初始化 @@ -66,6 +78,7 @@ document.addEventListener('DOMContentLoaded', () => { loadStats(); loadOutlookServices(); loadCustomServices(); + loadTempMailServices(); loadTempmailConfig(); initEventListeners(); }); @@ -158,6 +171,26 @@ function initEventListeners() { // 临时邮箱配置 elements.tempmailForm.addEventListener('submit', handleSaveTempmail); elements.testTempmailBtn.addEventListener('click', handleTestTempmail); + + // Temp-Mail 服务 + elements.addTempMailBtn.addEventListener('click', () => { + elements.addTempMailModal.classList.add('active'); + }); + elements.closeAddTempMailModal.addEventListener('click', () => { + elements.addTempMailModal.classList.remove('active'); + }); + elements.cancelAddTempMail.addEventListener('click', () => { + elements.addTempMailModal.classList.remove('active'); + }); + elements.addTempMailForm.addEventListener('submit', handleAddTempMail); + + elements.closeEditTempMailModal.addEventListener('click', () => { + elements.editTempMailModal.classList.remove('active'); + }); + elements.cancelEditTempMail.addEventListener('click', () => { + elements.editTempMailModal.classList.remove('active'); + }); + elements.editTempMailForm.addEventListener('submit', handleEditTempMail); } // 加载统计信息 @@ -677,3 +710,134 @@ async function handleEditOutlook(e) { toast.error('更新失败: ' + error.message); } } + + +// ============== Temp-Mail 服务功能 ============== + +// 加载 Temp-Mail 服务列表 +async function loadTempMailServices() { + try { + const data = await api.get('/email-services?service_type=temp_mail'); + const services = data.services || []; + + if (services.length === 0) { + elements.tempMailTable.innerHTML = ` +
| 名称 | +Worker 地址 | +邮箱域名 | +状态 | +优先级 | +操作 | +
|---|---|---|---|---|---|
|
+
+
+
+
+ |
+ |||||