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 = ` + +
+
📮
+
暂无 Temp-Mail 服务
+
点击「添加服务」配置自部署 Cloudflare Worker 临时邮箱
+
+ + `; + return; + } + + elements.tempMailTable.innerHTML = services.map(service => { + const config = service.config || {}; + return ` + + ${escapeHtml(service.name)} + ${escapeHtml(config.base_url || '-')} + ${escapeHtml(config.domain || '-')} + + + ${service.enabled ? '已启用' : '已禁用'} + + + ${service.priority || 0} + +
+ + + + +
+ + + `; + }).join(''); + } catch (error) { + console.error('加载 Temp-Mail 服务失败:', error); + } +} + +// 添加 Temp-Mail 服务 +async function handleAddTempMail(e) { + e.preventDefault(); + const formData = new FormData(e.target); + const data = { + service_type: 'temp_mail', + name: formData.get('name'), + config: { + base_url: formData.get('base_url'), + admin_password: formData.get('admin_password'), + domain: formData.get('domain'), + enable_prefix: true + }, + enabled: formData.get('enabled') === 'on', + priority: parseInt(formData.get('priority')) || 0 + }; + try { + await api.post('/email-services', data); + toast.success('服务添加成功'); + elements.addTempMailModal.classList.remove('active'); + e.target.reset(); + loadTempMailServices(); + loadStats(); + } catch (error) { + toast.error('添加失败: ' + error.message); + } +} + +// 编辑 Temp-Mail 服务 +async function editTempMailService(id) { + try { + const service = await api.get(`/email-services/${id}/full`); + document.getElementById('edit-tm-id').value = service.id; + document.getElementById('edit-tm-name').value = service.name || ''; + document.getElementById('edit-tm-base-url').value = service.config?.base_url || ''; + document.getElementById('edit-tm-admin-password').value = ''; + document.getElementById('edit-tm-admin-password').placeholder = service.config?.admin_password ? '已设置,留空保持不变' : '请输入 Admin 密码'; + document.getElementById('edit-tm-domain').value = service.config?.domain || ''; + document.getElementById('edit-tm-priority').value = service.priority || 0; + document.getElementById('edit-tm-enabled').checked = service.enabled; + elements.editTempMailModal.classList.add('active'); + } catch (error) { + toast.error('获取服务信息失败: ' + error.message); + } +} + +// 保存编辑 Temp-Mail 服务 +async function handleEditTempMail(e) { + e.preventDefault(); + const id = document.getElementById('edit-tm-id').value; + const formData = new FormData(e.target); + const config = { + base_url: formData.get('base_url'), + domain: formData.get('domain'), + enable_prefix: true + }; + // 只有填写了密码才更新 + const pwd = formData.get('admin_password'); + if (pwd && pwd.trim()) { + config.admin_password = pwd.trim(); + } + const updateData = { + name: formData.get('name'), + priority: parseInt(formData.get('priority')) || 0, + enabled: formData.get('enabled') === 'on', + config + }; + try { + await api.patch(`/email-services/${id}`, updateData); + toast.success('服务更新成功'); + elements.editTempMailModal.classList.remove('active'); + loadTempMailServices(); + loadStats(); + } catch (error) { + toast.error('更新失败: ' + error.message); + } +} diff --git a/static/js/utils.js b/static/js/utils.js index 43c39ea..3c17184 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -351,7 +351,8 @@ const statusMap = { service: { tempmail: 'Tempmail.lol', outlook: 'Outlook', - custom_domain: '自定义域名' + custom_domain: '自定义域名', + temp_mail: 'Temp-Mail(自部署)' } }; diff --git a/templates/email_services.html b/templates/email_services.html index 4113194..dce1c41 100644 --- a/templates/email_services.html +++ b/templates/email_services.html @@ -127,6 +127,40 @@ + +
+
+

📮 Temp-Mail 服务(自部署)

+ +
+
+
+ + + + + + + + + + + + + + + + +
名称Worker 地址邮箱域名状态优先级操作
+
+
+
+
+
+
+
+
+
@@ -332,6 +366,99 @@
+ + + + + +