From 91120a2fb48aba2712d96f15ca18c16d85a08033 Mon Sep 17 00:00:00 2001 From: rockxsj Date: Thu, 19 Mar 2026 16:41:30 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=A2=9E=E5=8A=A0duckmail=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + docs/plans/2026-03-19-duckmail-design.md | 77 ++++ .../2026-03-19-duckmail-implementation.md | 151 ++++++++ llm-api-docs.txt | 231 +++++++++++ src/config/constants.py | 10 +- src/services/__init__.py | 5 +- src/services/duck_mail.py | 366 ++++++++++++++++++ src/web/routes/email.py | 14 + src/web/routes/registration.py | 40 ++ static/js/app.js | 34 +- static/js/email_services.js | 114 ++++-- templates/email_services.html | 54 ++- templates/index.html | 6 +- tests/test_duck_mail_service.py | 143 +++++++ tests/test_email_service_duckmail_routes.py | 94 +++++ 15 files changed, 1294 insertions(+), 47 deletions(-) create mode 100644 docs/plans/2026-03-19-duckmail-design.md create mode 100644 docs/plans/2026-03-19-duckmail-implementation.md create mode 100644 llm-api-docs.txt create mode 100644 src/services/duck_mail.py create mode 100644 tests/test_duck_mail_service.py create mode 100644 tests/test_email_service_duckmail_routes.py diff --git a/README.md b/README.md index 2b70647..8f877f8 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ - 自定义域名(两种子类型) - **MoeMail**:标准 REST API,配置 API 地址 + API 密钥 - **TempMail**:自部署 Cloudflare Worker 临时邮箱,配置 Worker 地址 + Admin 密码 + - DuckMail + - **DuckMail API**:兼容 DuckMail 接口,手动填写 API 地址、默认域名,可选 API Key - **注册模式** - 单次注册 diff --git a/docs/plans/2026-03-19-duckmail-design.md b/docs/plans/2026-03-19-duckmail-design.md new file mode 100644 index 0000000..355ff13 --- /dev/null +++ b/docs/plans/2026-03-19-duckmail-design.md @@ -0,0 +1,77 @@ +# DuckMail 邮箱服务设计说明 + +**目标:** 为系统新增独立的 `duck_mail` 邮箱服务类型,支持在邮箱服务管理页配置 DuckMail,并在注册页选择该服务进行注册。 + +## 背景 + +当前项目已有三类邮箱服务: + +- `tempmail`:公共临时邮箱 +- `custom_domain`:MoeMail 风格 REST API +- `temp_mail`:自部署 Cloudflare Worker 临时邮箱 + +DuckMail 的接口模型与 `custom_domain` 不兼容。它采用 `/accounts`、`/token`、`/messages` 资源模型,并通过 Bearer Token 或 API Key 访问。因此需要新增独立服务类型,而不是复用现有 `custom_domain` 实现。 + +## 设计决策 + +### 1. 独立服务类型 + +新增 `duck_mail` 枚举值、服务类、前后端配置项与注册页可见性逻辑,避免与现有 MoeMail 接口混淆。 + +### 2. 配置模型 + +DuckMail 仅支持手动填写默认域名,不预拉取 `/domains`,配置项如下: + +- `base_url`:DuckMail API 地址 +- `default_domain`:默认域名,创建邮箱时直接拼接 +- `password_length`:自动创建 DuckMail 账户时使用的随机密码长度 +- `api_key`:可选,私有域名时通过 `Authorization: Bearer dk_xxx` 调用 +- `timeout` +- `max_retries` +- `proxy_url` + +### 3. 创建邮箱流程 + +DuckMail 服务在 `create_email()` 中: + +1. 生成合法用户名(至少 3 个字符) +2. 用 `default_domain` 拼接邮箱地址 +3. 生成随机密码 +4. 调用 `POST /accounts` +5. 调用 `POST /token` 获取 Bearer Token +6. 返回 `email`、`service_id`、`account_id`、`token`、内部随机密码等信息 + +### 4. 取验证码流程 + +DuckMail 的列表接口不返回正文,需: + +1. 轮询 `GET /messages` +2. 筛选 OpenAI 邮件 +3. 对新消息调用 `GET /messages/{id}` +4. 从 `text/html` 中提取验证码 + +### 5. 删除与健康检查 + +- `delete_email()`:使用账户 Bearer Token 调 `DELETE /accounts/{id}` +- `check_health()`:优先调 `GET /domains`;如果配置了私有域名 API Key,也沿用相同入口 + +### 6. 前端接入 + +邮箱服务管理页新增 DuckMail 子类型: + +- 列表中单独展示为 DuckMail +- 新建/编辑表单展示 DuckMail 专属字段 + +注册页: + +- 从 `/registration/available-services` 中显示 DuckMail 服务 +- 选择后与其他数据库邮箱服务一样传递 `email_service_type=duck_mail` 与 `email_service_id` + +## 测试范围 + +至少覆盖: + +- DuckMail `create_email()` 会调用账户创建与 token 获取 +- DuckMail `get_verification_code()` 会按“列表 -> 详情”提取验证码 +- 注册接口的可用服务列表包含 `duck_mail` +- 邮箱服务类型接口与敏感字段过滤支持 `duck_mail` diff --git a/docs/plans/2026-03-19-duckmail-implementation.md b/docs/plans/2026-03-19-duckmail-implementation.md new file mode 100644 index 0000000..53bd144 --- /dev/null +++ b/docs/plans/2026-03-19-duckmail-implementation.md @@ -0,0 +1,151 @@ +# DuckMail 邮箱服务实现计划 + +> **给 Codex:** 必须按 TDD 执行本计划,先写失败测试,再写最小实现。 + +**目标:** 新增独立 `duck_mail` 邮箱服务类型,支持 DuckMail 配置、测试、注册可见性与实际验证码拉取。 + +**架构:** 在现有 `BaseEmailService` 体系中新增 `DuckMailService`,并把它接入服务工厂、邮箱服务管理 API、注册可用服务 API 与前端页面。DuckMail 不复用 `custom_domain` 逻辑,单独维护请求头、创建邮箱、登录取 token、拉取消息与删除账户逻辑。 + +**技术栈:** Python、FastAPI、SQLAlchemy、原生 JavaScript、pytest + +--- + +### 任务 1:补 DuckMail 服务层测试 + +**文件:** +- 新建:`tests/test_duck_mail_service.py` +- 参考:`src/services/temp_mail.py` +- 参考:`src/services/moe_mail.py` + +**步骤 1:写失败测试** + +- 测试 `create_email()`: + - 会先调用 `POST /accounts` + - 再调用 `POST /token` + - 返回值包含 `email`、`service_id`、`account_id`、`token` +- 测试 `get_verification_code()`: + - 先调 `GET /messages` + - 再调 `GET /messages/{id}` + - 能从正文提取 6 位验证码 + +**步骤 2:运行失败测试** + +运行:`pytest tests/test_duck_mail_service.py -q` + +预期:因为 `DuckMailService` 尚不存在而失败。 + +**步骤 3:写最小实现** + +- 新增 `src/services/duck_mail.py` +- 只实现本轮测试所需的最小方法: + - 初始化配置 + - 请求封装 + - 创建邮箱 + - 获取 token + - 拉取消息与验证码 + - 删除账户 + - 健康检查 + +**步骤 4:运行测试确认通过** + +运行:`pytest tests/test_duck_mail_service.py -q` + +### 任务 2:补服务枚举与工厂接入测试 + +**文件:** +- 新建:`tests/test_email_service_duckmail_routes.py` +- 修改:`src/config/constants.py` +- 修改:`src/services/__init__.py` + +**步骤 1:写失败测试** + +- 断言 `EmailServiceType("duck_mail")` 可用 +- 断言 `EmailServiceFactory.get_service_class(EmailServiceType.DUCK_MAIL)` 已注册 + +**步骤 2:运行失败测试** + +运行:`pytest tests/test_email_service_duckmail_routes.py::test_duck_mail_service_registered -q` + +**步骤 3:写最小实现** + +- 在枚举中加入 `DUCK_MAIL` +- 在服务工厂注册 `DuckMailService` + +**步骤 4:运行测试确认通过** + +运行:`pytest tests/test_email_service_duckmail_routes.py::test_duck_mail_service_registered -q` + +### 任务 3:补邮箱服务 API 与注册可见性测试 + +**文件:** +- 修改:`src/web/routes/email.py` +- 修改:`src/web/routes/registration.py` +- 参考:`src/database/models.py` + +**步骤 1:写失败测试** + +- 测试邮箱服务类型接口包含 `duck_mail` +- 测试敏感配置过滤支持 `api_key` +- 测试 `/registration/available-services` 会返回 `duck_mail` + +**步骤 2:运行失败测试** + +运行:`pytest tests/test_email_service_duckmail_routes.py -q` + +**步骤 3:写最小实现** + +- `email.py` + - 统计增加 `duck_mail_count` + - `get_service_types()` 增加 DuckMail 配置项 +- `registration.py` + - `available-services` 增加 `duck_mail` + - `_normalize_email_service_config()` 支持 DuckMail 字段 + - `_run_sync_registration_task()` 支持 DuckMail 默认选择逻辑 + +**步骤 4:运行测试确认通过** + +运行:`pytest tests/test_email_service_duckmail_routes.py -q` + +### 任务 4:补前端 DuckMail 配置与注册入口 + +**文件:** +- 修改:`templates/email_services.html` +- 修改:`static/js/email_services.js` +- 修改:`static/js/app.js` +- 可选修改:`templates/index.html` + +**步骤 1:先补最小前端逻辑** + +- 邮箱服务页新增 DuckMail 类型展示与新建/编辑字段 +- 注册页邮箱服务下拉新增 DuckMail 分组 +- 选择 DuckMail 时传 `duck_mail:` + +**步骤 2:人工自检** + +- 检查新增字段是否与后端字段名一致: + - `base_url` + - `api_key` + - `default_domain` + - `password_length` + +### 任务 5:完整验证 + +**文件:** +- 修改:`README.md` + +**步骤 1:补文档** + +- 在功能列表与邮箱服务说明中加入 DuckMail + +**步骤 2:运行完整验证** + +运行:`pytest tests/test_duck_mail_service.py tests/test_email_service_duckmail_routes.py -q` + +如环境允许,再运行: + +`python -m compileall src` + +**步骤 3:检查结果** + +- 确认 pytest 退出码为 0 +- 确认编译检查无语法错误 diff --git a/llm-api-docs.txt b/llm-api-docs.txt new file mode 100644 index 0000000..ff93366 --- /dev/null +++ b/llm-api-docs.txt @@ -0,0 +1,231 @@ +# DuckMail API Reference +# Base URL: https://api.duckmail.sbs +# Authentication: Bearer Token or API Key (dk_xxx) +# This file is designed to be sent to AI assistants for integration help. + +--- + +## Authentication + +### Bearer Token +Obtain a token via POST /token with email address and password. +Include in requests: Authorization: Bearer + +### API Key (Optional) +For private domain access. Obtain from https://domain.duckmail.sbs +Format: dk_ prefix. Include in requests: Authorization: Bearer dk_xxx + +--- + +## Endpoints + +### [Domains] GET /domains +Get available domain list. Returns private domains if API key is provided. +Auth: Optional (API Key or Bearer Token) +Query: page (default 1, 30 per page) +Response: +{ + "hydra:member": [ + { + "id": "string", + "domain": "example.com", + "ownerId": "string | null", + "isVerified": true, + "verificationToken": "duckmail-verify-xxx", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" + } + ], + "hydra:totalItems": 10, + "hydra:view": { + "@id": "/domains?page=1", + "@type": "PartialCollectionView", + "hydra:first": "/domains?page=1", + "hydra:last": "/domains?page=1" + } +} +Note: Only verified domains are returned. System domains (ownerId=null) are visible to all. + +### [Accounts] POST /accounts +Create a new email account. API key required for private domains. +Auth: Optional (API Key or Bearer Token) +Request: +{ + "address": "user@duckmail.sbs", + "password": "your_password", + "expiresIn": 86400 +} +Fields: +- address (required): email address. Username (before @) >= 3 chars, domain must be verified. +- password (required): >= 6 chars. +- expiresIn (optional, integer, seconds): Account expiry. Omit = 24h auto-cleanup. 0 or -1 = never expires. Positive number = custom expiry in seconds. +Validation: address must contain @, username (before @) >= 3 chars, password >= 6 chars, domain must be verified. +Response (201): +{ + "id": "string", + "address": "user@duckmail.sbs", + "authType": "email", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" +} + +### [Auth] POST /token +Get authentication token using email and password. +Auth: None +Request: +{ + "address": "user@duckmail.sbs", + "password": "your_password" +} +Response: +{ + "id": "account-id", + "token": "eyJhbGc..." +} + +### [Accounts] GET /me +Get current authenticated account info. +Auth: Required (Bearer Token) +Response: +{ + "id": "string", + "address": "user@duckmail.sbs", + "authType": "email", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" +} + +### [Accounts] DELETE /accounts/{id} +Delete your own account by ID. You can only delete the currently logged-in account. +Auth: Required (Bearer Token) +Response: 204 No Content + +### [Messages] GET /messages +Get inbox message list (paginated, newest first). +Auth: Required (Bearer Token) +Query: page (default 1, 30 per page) +Response: +{ + "hydra:member": [ + { + "id": "string", + "msgid": "string", + "accountId": "string", + "from": { "name": "Sender", "address": "sender@example.com" }, + "to": [{ "name": "You", "address": "you@duckmail.sbs" }], + "subject": "Email Subject", + "seen": false, + "isDeleted": false, + "hasAttachments": false, + "size": 1024, + "downloadUrl": "/serve/mailbox/...", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" + } + ], + "hydra:totalItems": 5, + "hydra:view": { ... } +} +Note: List view does not include text/html body content. + +### [Messages] GET /messages/{id} +Get full message details including body and attachments. +Auth: Required (Bearer Token) +Response: +{ + "id": "string", + "msgid": "string", + "accountId": "string", + "from": { "name": "Sender", "address": "sender@example.com" }, + "to": [{ "name": "You", "address": "you@duckmail.sbs" }], + "subject": "Email Subject", + "text": "Plain text body", + "html": ["..."], + "seen": false, + "isDeleted": false, + "hasAttachments": true, + "size": 2048, + "downloadUrl": "/serve/mailbox/...", + "attachments": [ + { + "id": "0", + "filename": "document.pdf", + "contentType": "application/pdf", + "disposition": "attachment", + "transferEncoding": "", + "related": false, + "size": 1024, + "downloadUrl": "/serve/mailbox/.../attach/0/document.pdf" + } + ], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" +} + +### [Messages] PATCH /messages/{id} +Mark a message as read. +Auth: Required (Bearer Token) +Response: { "seen": true } + +### [Messages] DELETE /messages/{id} +Delete a message by ID. +Auth: Required (Bearer Token) +Response: 204 No Content + +### [Messages] GET /sources/{id} +Get raw email source (RFC 822 format). +Auth: Required (Bearer Token) +Response: +{ + "id": "string", + "downloadUrl": "/serve/mailbox/.../source", + "data": "From: sender@example.com\nTo: ..." +} + +--- + +## Error Response Format + +{ + "error": "Error Type", + "message": "Detailed error message" +} + +Status codes: +- 400: Bad Request (invalid format) +- 401: Unauthorized (missing or invalid token) +- 403: Forbidden (no permission) +- 404: Not Found +- 409: Conflict (e.g. email address already exists) +- 422: Unprocessable Entity (validation failed) +- 500: Internal Server Error + +--- + +## Quick Start Example + +# 1. Create account (expiresIn: omit=24h, 0/-1=never, >0=custom seconds) +curl -X POST https://api.duckmail.sbs/accounts \ + -H "Content-Type: application/json" \ + -d '{"address": "test@duckmail.sbs", "password": "mypassword", "expiresIn": 0}' + +# 2. Get token +curl -X POST https://api.duckmail.sbs/token \ + -H "Content-Type: application/json" \ + -d '{"address": "test@duckmail.sbs", "password": "mypassword"}' + +# 3. Read messages +curl https://api.duckmail.sbs/messages \ + -H "Authorization: Bearer " + +# 4. Get message detail +curl https://api.duckmail.sbs/messages/ \ + -H "Authorization: Bearer " + +# 5. Mark message as read +curl -X PATCH https://api.duckmail.sbs/messages/ \ + -H "Authorization: Bearer " + +# 6. Get domains (with API key for private domains) +curl https://api.duckmail.sbs/domains \ + -H "Authorization: Bearer dk_your_api_key" diff --git a/src/config/constants.py b/src/config/constants.py index dcf24ee..c56b241 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -35,6 +35,7 @@ class EmailServiceType(str, Enum): OUTLOOK = "outlook" CUSTOM_DOMAIN = "custom_domain" TEMP_MAIL = "temp_mail" + DUCK_MAIL = "duck_mail" # ============================================================================ @@ -112,6 +113,13 @@ EMAIL_SERVICE_DEFAULTS = { "api_key_header": "X-API-Key", "timeout": 30, "max_retries": 3, + }, + "duck_mail": { + "base_url": "", + "default_domain": "", + "password_length": 12, + "timeout": 30, + "max_retries": 3, } } @@ -368,4 +376,4 @@ MICROSOFT_SCOPES = { } # Outlook 提供者默认优先级 -OUTLOOK_PROVIDER_PRIORITY = ["imap_new", "imap_old", "graph_api"] \ No newline at end of file +OUTLOOK_PROVIDER_PRIORITY = ["imap_new", "imap_old", "graph_api"] diff --git a/src/services/__init__.py b/src/services/__init__.py index 816d60b..e3fdd51 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -14,12 +14,14 @@ from .tempmail import TempmailService from .outlook import OutlookService from .moe_mail import MeoMailEmailService from .temp_mail import TempMailService +from .duck_mail import DuckMailService # 注册服务 EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService) EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService) EmailServiceFactory.register(EmailServiceType.CUSTOM_DOMAIN, MeoMailEmailService) EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService) +EmailServiceFactory.register(EmailServiceType.DUCK_MAIL, DuckMailService) # 导出 Outlook 模块的额外内容 from .outlook.base import ( @@ -50,6 +52,7 @@ __all__ = [ 'OutlookService', 'MeoMailEmailService', 'TempMailService', + 'DuckMailService', # Outlook 模块 'ProviderType', 'EmailMessage', @@ -61,4 +64,4 @@ __all__ = [ 'IMAPOldProvider', 'IMAPNewProvider', 'GraphAPIProvider', -] \ No newline at end of file +] diff --git a/src/services/duck_mail.py b/src/services/duck_mail.py new file mode 100644 index 0000000..deb911a --- /dev/null +++ b/src/services/duck_mail.py @@ -0,0 +1,366 @@ +""" +DuckMail 邮箱服务实现 +兼容 DuckMail 的 accounts/token/messages 接口模型 +""" + +import logging +import random +import re +import string +import time +from datetime import datetime, timezone +from html import unescape +from typing import Any, Dict, List, Optional + +from .base import BaseEmailService, EmailServiceError, EmailServiceType +from ..config.constants import OTP_CODE_PATTERN +from ..core.http_client import HTTPClient, RequestConfig + + +logger = logging.getLogger(__name__) + + +class DuckMailService(BaseEmailService): + """DuckMail 邮箱服务""" + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + super().__init__(EmailServiceType.DUCK_MAIL, name) + + required_keys = ["base_url", "default_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 = { + "api_key": "", + "password_length": 12, + "expires_in": None, + "timeout": 30, + "max_retries": 3, + "proxy_url": None, + } + self.config = {**default_config, **(config or {})} + self.config["base_url"] = str(self.config["base_url"]).rstrip("/") + self.config["default_domain"] = str(self.config["default_domain"]).strip().lstrip("@") + + http_config = RequestConfig( + timeout=self.config["timeout"], + max_retries=self.config["max_retries"], + ) + self.http_client = HTTPClient( + proxy_url=self.config.get("proxy_url"), + config=http_config, + ) + + self._accounts_by_id: Dict[str, Dict[str, Any]] = {} + self._accounts_by_email: Dict[str, Dict[str, Any]] = {} + + def _build_headers( + self, + token: Optional[str] = None, + use_api_key: bool = False, + extra_headers: Optional[Dict[str, str]] = None, + ) -> Dict[str, str]: + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + + auth_token = token + if not auth_token and use_api_key and self.config.get("api_key"): + auth_token = self.config["api_key"] + + if auth_token: + headers["Authorization"] = f"Bearer {auth_token}" + + if extra_headers: + headers.update(extra_headers) + + return headers + + def _make_request( + self, + method: str, + path: str, + token: Optional[str] = None, + use_api_key: bool = False, + **kwargs, + ) -> Dict[str, Any]: + url = f"{self.config['base_url']}{path}" + kwargs["headers"] = self._build_headers( + token=token, + use_api_key=use_api_key, + extra_headers=kwargs.get("headers"), + ) + + try: + response = self.http_client.request(method, url, **kwargs) + if response.status_code >= 400: + error_message = f"API 请求失败: {response.status_code}" + try: + error_payload = response.json() + error_message = f"{error_message} - {error_payload}" + except Exception: + error_message = f"{error_message} - {response.text[:200]}" + raise EmailServiceError(error_message) + + try: + return response.json() + except Exception: + 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 _generate_local_part(self) -> str: + first = random.choice(string.ascii_lowercase) + rest = "".join(random.choices(string.ascii_lowercase + string.digits, k=7)) + return f"{first}{rest}" + + def _generate_password(self) -> str: + length = max(6, int(self.config.get("password_length") or 12)) + alphabet = string.ascii_letters + string.digits + return "".join(random.choices(alphabet, k=length)) + + def _cache_account(self, account_info: Dict[str, Any]) -> None: + account_id = str(account_info.get("account_id") or account_info.get("service_id") or "").strip() + email = str(account_info.get("email") or "").strip().lower() + + if account_id: + self._accounts_by_id[account_id] = account_info + if email: + self._accounts_by_email[email] = account_info + + def _get_account_info(self, email: Optional[str] = None, email_id: Optional[str] = None) -> Optional[Dict[str, Any]]: + if email_id: + cached = self._accounts_by_id.get(str(email_id)) + if cached: + return cached + + if email: + cached = self._accounts_by_email.get(str(email).strip().lower()) + if cached: + return cached + + return None + + def _strip_html(self, html_content: Any) -> str: + if isinstance(html_content, list): + html_content = "\n".join(str(item) for item in html_content if item) + text = str(html_content or "") + return unescape(re.sub(r"<[^>]+>", " ", text)) + + def _parse_message_time(self, value: Optional[str]) -> Optional[float]: + if not value: + return None + try: + normalized = value.replace("Z", "+00:00") + return datetime.fromisoformat(normalized).astimezone(timezone.utc).timestamp() + except Exception: + return None + + def _message_search_text(self, summary: Dict[str, Any], detail: Dict[str, Any]) -> str: + sender = summary.get("from") or detail.get("from") or {} + if isinstance(sender, dict): + sender_text = " ".join( + str(sender.get(key) or "") for key in ("name", "address") + ).strip() + else: + sender_text = str(sender) + + subject = str(summary.get("subject") or detail.get("subject") or "") + text_body = str(detail.get("text") or "") + html_body = self._strip_html(detail.get("html")) + return "\n".join(part for part in [sender_text, subject, text_body, html_body] if part).strip() + + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + request_config = config or {} + local_part = str(request_config.get("name") or self._generate_local_part()).strip() + domain = str(request_config.get("default_domain") or request_config.get("domain") or self.config["default_domain"]).strip().lstrip("@") + address = f"{local_part}@{domain}" + password = self._generate_password() + + payload: Dict[str, Any] = { + "address": address, + "password": password, + } + + expires_in = request_config.get("expiresIn", request_config.get("expires_in", self.config.get("expires_in"))) + if expires_in is not None: + payload["expiresIn"] = expires_in + + account_response = self._make_request( + "POST", + "/accounts", + json=payload, + use_api_key=bool(self.config.get("api_key")), + ) + token_response = self._make_request( + "POST", + "/token", + json={ + "address": account_response.get("address", address), + "password": password, + }, + ) + + account_id = str(account_response.get("id") or token_response.get("id") or "").strip() + resolved_address = str(account_response.get("address") or address).strip() + token = str(token_response.get("token") or "").strip() + + if not account_id or not resolved_address or not token: + raise EmailServiceError("DuckMail 返回数据不完整") + + email_info = { + "email": resolved_address, + "service_id": account_id, + "id": account_id, + "account_id": account_id, + "token": token, + "password": password, + "created_at": time.time(), + "raw_account": account_response, + } + + self._cache_account(email_info) + self.update_status(True) + return email_info + + def get_verification_code( + self, + email: str, + email_id: str = None, + timeout: int = 120, + pattern: str = OTP_CODE_PATTERN, + otp_sent_at: Optional[float] = None, + ) -> Optional[str]: + account_info = self._get_account_info(email=email, email_id=email_id) + if not account_info: + logger.warning(f"DuckMail 未找到邮箱缓存: {email}, {email_id}") + return None + + token = account_info.get("token") + if not token: + logger.warning(f"DuckMail 邮箱缺少访问 token: {email}") + return None + + start_time = time.time() + seen_message_ids = set() + + while time.time() - start_time < timeout: + try: + response = self._make_request( + "GET", + "/messages", + token=token, + params={"page": 1}, + ) + messages = response.get("hydra:member", []) + + for message in messages: + message_id = str(message.get("id") or "").strip() + if not message_id or message_id in seen_message_ids: + continue + + created_at = self._parse_message_time(message.get("createdAt")) + if otp_sent_at and created_at and created_at + 1 < otp_sent_at: + continue + + seen_message_ids.add(message_id) + detail = self._make_request( + "GET", + f"/messages/{message_id}", + token=token, + ) + + content = self._message_search_text(message, detail) + if "openai" not in content.lower(): + continue + + match = re.search(pattern, content) + if match: + self.update_status(True) + return match.group(1) + except Exception as e: + logger.debug(f"DuckMail 轮询验证码失败: {e}") + + time.sleep(3) + + return None + + def list_emails(self, **kwargs) -> List[Dict[str, Any]]: + return list(self._accounts_by_email.values()) + + def delete_email(self, email_id: str) -> bool: + account_info = self._get_account_info(email_id=email_id) or self._get_account_info(email=email_id) + if not account_info: + return False + + token = account_info.get("token") + account_id = account_info.get("account_id") or account_info.get("service_id") + if not token or not account_id: + return False + + try: + self._make_request( + "DELETE", + f"/accounts/{account_id}", + token=token, + ) + self._accounts_by_id.pop(str(account_id), None) + self._accounts_by_email.pop(str(account_info.get("email") or "").lower(), None) + self.update_status(True) + return True + except Exception as e: + logger.warning(f"DuckMail 删除邮箱失败: {e}") + self.update_status(False, e) + return False + + def check_health(self) -> bool: + try: + self._make_request( + "GET", + "/domains", + params={"page": 1}, + use_api_key=bool(self.config.get("api_key")), + ) + self.update_status(True) + return True + except Exception as e: + logger.warning(f"DuckMail 健康检查失败: {e}") + self.update_status(False, e) + return False + + def get_email_messages(self, email_id: str, **kwargs) -> List[Dict[str, Any]]: + account_info = self._get_account_info(email_id=email_id) or self._get_account_info(email=email_id) + if not account_info or not account_info.get("token"): + return [] + response = self._make_request( + "GET", + "/messages", + token=account_info["token"], + params={"page": kwargs.get("page", 1)}, + ) + return response.get("hydra:member", []) + + def get_message_detail(self, email_id: str, message_id: str) -> Optional[Dict[str, Any]]: + account_info = self._get_account_info(email_id=email_id) or self._get_account_info(email=email_id) + if not account_info or not account_info.get("token"): + return None + return self._make_request( + "GET", + f"/messages/{message_id}", + token=account_info["token"], + ) + + def get_service_info(self) -> Dict[str, Any]: + return { + "service_type": self.service_type.value, + "name": self.name, + "base_url": self.config["base_url"], + "default_domain": self.config["default_domain"], + "cached_accounts": len(self._accounts_by_email), + "status": self.status.value, + } diff --git a/src/web/routes/email.py b/src/web/routes/email.py index b2b4499..b577ab7 100644 --- a/src/web/routes/email.py +++ b/src/web/routes/email.py @@ -144,6 +144,7 @@ async def get_email_services_stats(): 'outlook_count': 0, 'custom_count': 0, 'temp_mail_count': 0, + 'duck_mail_count': 0, 'tempmail_available': True, # 临时邮箱始终可用 'enabled_count': enabled_count } @@ -155,6 +156,8 @@ async def get_email_services_stats(): stats['custom_count'] = count elif service_type == 'temp_mail': stats['temp_mail_count'] = count + elif service_type == 'duck_mail': + stats['duck_mail_count'] = count return stats @@ -204,6 +207,17 @@ async def get_service_types(): {"name": "domain", "label": "邮箱域名", "required": True, "placeholder": "example.com"}, {"name": "enable_prefix", "label": "启用前缀", "required": False, "default": True}, ] + }, + { + "value": "duck_mail", + "label": "DuckMail", + "description": "DuckMail 接口邮箱服务,支持 API Key 私有域名访问", + "config_fields": [ + {"name": "base_url", "label": "API 地址", "required": True, "placeholder": "https://api.duckmail.sbs"}, + {"name": "default_domain", "label": "默认域名", "required": True, "placeholder": "duckmail.sbs"}, + {"name": "api_key", "label": "API Key", "required": False, "secret": True}, + {"name": "password_length", "label": "随机密码长度", "required": False, "default": 12}, + ] } ] } diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index 2bff131..8e878a6 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -211,6 +211,9 @@ def _normalize_email_service_config( elif service_type == EmailServiceType.TEMP_MAIL: if 'default_domain' in normalized and 'domain' not in normalized: normalized['domain'] = normalized.pop('default_domain') + elif service_type == EmailServiceType.DUCK_MAIL: + if 'domain' in normalized and 'default_domain' not in normalized: + normalized['default_domain'] = normalized.pop('domain') if proxy_url and 'proxy_url' not in normalized: normalized['proxy_url'] = proxy_url @@ -341,6 +344,20 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: logger.info(f"使用数据库 Outlook 账户: {selected_service.name}") else: raise ValueError("所有 Outlook 账户都已注册过 OpenAI 账号,请添加新的 Outlook 账户") + elif service_type == EmailServiceType.DUCK_MAIL: + from ...database.models import EmailService as EmailServiceModel + + db_service = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "duck_mail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).first() + + if db_service and db_service.config: + config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url) + crud.update_registration_task(db, task_uuid, email_service_id=db_service.id) + logger.info(f"使用数据库 DuckMail 服务: {db_service.name}") + else: + raise ValueError("没有可用的 DuckMail 邮箱服务,请先在邮箱服务页面添加服务") else: config = email_service_config or {} @@ -1069,6 +1086,11 @@ async def get_available_email_services(): "available": False, "count": 0, "services": [] + }, + "duck_mail": { + "available": False, + "count": 0, + "services": [] } } @@ -1142,6 +1164,24 @@ async def get_available_email_services(): result["temp_mail"]["count"] = len(temp_mail_services) result["temp_mail"]["available"] = len(temp_mail_services) > 0 + duck_mail_services = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "duck_mail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).all() + + for service in duck_mail_services: + config = service.config or {} + result["duck_mail"]["services"].append({ + "id": service.id, + "name": service.name, + "type": "duck_mail", + "default_domain": config.get("default_domain"), + "priority": service.priority + }) + + result["duck_mail"]["count"] = len(duck_mail_services) + result["duck_mail"]["available"] = len(duck_mail_services) > 0 + return result diff --git a/static/js/app.js b/static/js/app.js index c240222..aaa15a6 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -22,7 +22,8 @@ let availableServices = { tempmail: { available: true, services: [] }, outlook: { available: false, services: [] }, custom_domain: { available: false, services: [] }, - temp_mail: { available: false, services: [] } + temp_mail: { available: false, services: [] }, + duck_mail: { available: false, services: [] } }; // WebSocket 相关变量 @@ -298,7 +299,7 @@ function updateEmailServiceOptions() { availableServices.custom_domain.services.forEach(service => { const option = document.createElement('option'); option.value = `custom_domain:${service.id || 'default'}`; - option.textContent = service.name; + option.textContent = service.name + (service.default_domain ? ` (@${service.default_domain})` : ''); option.dataset.type = 'custom_domain'; if (service.id) { option.dataset.serviceId = service.id; @@ -336,6 +337,23 @@ function updateEmailServiceOptions() { select.appendChild(optgroup); } + + // DuckMail + if (availableServices.duck_mail && availableServices.duck_mail.available) { + const optgroup = document.createElement('optgroup'); + optgroup.label = `🦆 DuckMail (${availableServices.duck_mail.count} 个服务)`; + + availableServices.duck_mail.services.forEach(service => { + const option = document.createElement('option'); + option.value = `duck_mail:${service.id}`; + option.textContent = service.name + (service.default_domain ? ` (@${service.default_domain})` : ''); + option.dataset.type = 'duck_mail'; + option.dataset.serviceId = service.id; + optgroup.appendChild(option); + }); + + select.appendChild(optgroup); + } } // 处理邮箱服务切换 @@ -344,8 +362,6 @@ function handleServiceChange(e) { if (!value) return; const [type, id] = value.split(':'); - const selectedOption = e.target.options[e.target.selectedIndex]; - // 处理 Outlook 批量注册模式 if (type === 'outlook_batch') { isOutlookBatchMode = true; @@ -373,6 +389,16 @@ function handleServiceChange(e) { if (service) { addLog('info', `[系统] 已选择自定义域名服务: ${service.name}`); } + } else if (type === 'temp_mail') { + const service = availableServices.temp_mail.services.find(s => s.id == id); + if (service) { + addLog('info', `[系统] 已选择 Temp-Mail 自部署服务: ${service.name}`); + } + } else if (type === 'duck_mail') { + const service = availableServices.duck_mail.services.find(s => s.id == id); + if (service) { + addLog('info', `[系统] 已选择 DuckMail 服务: ${service.name}`); + } } } diff --git a/static/js/email_services.js b/static/js/email_services.js index aca83cc..22e23eb 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -4,7 +4,7 @@ // 状态 let outlookServices = []; -let customServices = []; // 合并 custom_domain + temp_mail +let customServices = []; // 合并 custom_domain + temp_mail + duck_mail let selectedOutlook = new Set(); let selectedCustom = new Set(); @@ -50,6 +50,7 @@ const elements = { customSubType: document.getElementById('custom-sub-type'), addMoemailFields: document.getElementById('add-moemail-fields'), addTempmailFields: document.getElementById('add-tempmail-fields'), + addDuckmailFields: document.getElementById('add-duckmail-fields'), // 编辑自定义域名模态框 editCustomModal: document.getElementById('edit-custom-modal'), @@ -58,6 +59,7 @@ const elements = { cancelEditCustom: document.getElementById('cancel-edit-custom'), editMoemailFields: document.getElementById('edit-moemail-fields'), editTempmailFields: document.getElementById('edit-tempmail-fields'), + editDuckmailFields: document.getElementById('edit-duckmail-fields'), editCustomTypeBadge: document.getElementById('edit-custom-type-badge'), editCustomSubTypeHidden: document.getElementById('edit-custom-sub-type-hidden'), @@ -68,6 +70,12 @@ const elements = { cancelEditOutlook: document.getElementById('cancel-edit-outlook'), }; +const CUSTOM_SUBTYPE_LABELS = { + moemail: '🔗 MoeMail(自定义域名 API)', + tempmail: '📮 TempMail(自部署 Cloudflare Worker)', + duckmail: '🦆 DuckMail(DuckMail API)' +}; + // 初始化 document.addEventListener('DOMContentLoaded', () => { loadStats(); @@ -167,22 +175,18 @@ function closeEmailMoreMenu(el) { // 切换添加表单子类型 function switchAddSubType(subType) { elements.customSubType.value = subType; - if (subType === 'moemail') { - elements.addMoemailFields.style.display = ''; - elements.addTempmailFields.style.display = 'none'; - } else { - elements.addMoemailFields.style.display = 'none'; - elements.addTempmailFields.style.display = ''; - } + elements.addMoemailFields.style.display = subType === 'moemail' ? '' : 'none'; + elements.addTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; + elements.addDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; } // 切换编辑表单子类型显示 function switchEditSubType(subType) { elements.editCustomSubTypeHidden.value = subType; - const isMoe = subType === 'moemail'; - elements.editMoemailFields.style.display = isMoe ? '' : 'none'; - elements.editTempmailFields.style.display = isMoe ? 'none' : ''; - elements.editCustomTypeBadge.textContent = isMoe ? '🔗 MoeMail(自定义域名 API)' : '📮 TempMail(自部署 Cloudflare Worker)'; + elements.editMoemailFields.style.display = subType === 'moemail' ? '' : 'none'; + elements.editTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; + elements.editDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; + elements.editCustomTypeBadge.textContent = CUSTOM_SUBTYPE_LABELS[subType] || CUSTOM_SUBTYPE_LABELS.moemail; } // 加载统计信息 @@ -190,7 +194,7 @@ async function loadStats() { try { const data = await api.get('/email-services/stats'); elements.outlookCount.textContent = data.outlook_count || 0; - elements.customCount.textContent = (data.custom_count || 0) + (data.temp_mail_count || 0); + elements.customCount.textContent = (data.custom_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0); elements.tempmailStatus.textContent = data.tempmail_available ? '可用' : '不可用'; elements.totalEnabled.textContent = data.enabled_count || 0; } catch (error) { @@ -262,16 +266,37 @@ async function loadOutlookServices() { } } -// 加载自定义域名服务(custom_domain + temp_mail 合并) +function getCustomServiceTypeBadge(subType) { + if (subType === 'moemail') { + return 'MoeMail'; + } + if (subType === 'tempmail') { + return 'TempMail'; + } + return 'DuckMail'; +} + +function getCustomServiceAddress(service) { + const baseUrl = service.config?.base_url || '-'; + const domain = service.config?.default_domain || service.config?.domain; + if (!domain) { + return escapeHtml(baseUrl); + } + return `${escapeHtml(baseUrl)}
默认域名:@${escapeHtml(domain)}
`; +} + +// 加载自定义邮箱服务(custom_domain + temp_mail + duck_mail 合并) async function loadCustomServices() { try { - const [r1, r2] = await Promise.all([ + const [r1, r2, r3] = await Promise.all([ api.get('/email-services?service_type=custom_domain'), - api.get('/email-services?service_type=temp_mail') + api.get('/email-services?service_type=temp_mail'), + api.get('/email-services?service_type=duck_mail') ]); customServices = [ ...(r1.services || []).map(s => ({ ...s, _subType: 'moemail' })), - ...(r2.services || []).map(s => ({ ...s, _subType: 'tempmail' })) + ...(r2.services || []).map(s => ({ ...s, _subType: 'tempmail' })), + ...(r3.services || []).map(s => ({ ...s, _subType: 'duckmail' })) ]; if (customServices.length === 0) { @@ -280,7 +305,7 @@ async function loadCustomServices() {
📭
-
暂无自定义域名服务
+
暂无自定义邮箱服务
点击「添加服务」按钮创建新服务
@@ -290,15 +315,12 @@ async function loadCustomServices() { } elements.customTable.innerHTML = customServices.map(service => { - const isMoe = service._subType === 'moemail'; - const typeLabel = isMoe ? 'MoeMail' : 'TempMail'; - const addr = isMoe ? (service.config?.base_url || '-') : (service.config?.base_url || '-'); return ` ${escapeHtml(service.name)} - ${typeLabel} - ${escapeHtml(addr)} + ${getCustomServiceTypeBadge(service._subType)} + ${getCustomServiceAddress(service)} ${service.enabled ? '✅' : '⭕'} ${service.priority} ${format.date(service.last_used)} @@ -327,7 +349,7 @@ async function loadCustomServices() { }); } catch (error) { - console.error('加载自定义域名服务失败:', error); + console.error('加载自定义邮箱服务失败:', error); } } @@ -382,7 +404,7 @@ async function handleOutlookImport() { } } -// 添加自定义域名服务(根据子类型区分) +// 添加自定义邮箱服务(根据子类型区分) async function handleAddCustom(e) { e.preventDefault(); const formData = new FormData(e.target); @@ -396,7 +418,7 @@ async function handleAddCustom(e) { api_key: formData.get('api_key'), default_domain: formData.get('domain') }; - } else { + } else if (subType === 'tempmail') { serviceType = 'temp_mail'; config = { base_url: formData.get('tm_base_url'), @@ -404,6 +426,14 @@ async function handleAddCustom(e) { domain: formData.get('tm_domain'), enable_prefix: true }; + } else { + serviceType = 'duck_mail'; + config = { + base_url: formData.get('dm_base_url'), + api_key: formData.get('dm_api_key'), + default_domain: formData.get('dm_domain'), + password_length: parseInt(formData.get('dm_password_length'), 10) || 12 + }; } const data = { @@ -535,11 +565,17 @@ function escapeHtml(text) { // ============== 编辑功能 ============== -// 编辑自定义域名服务(支持 moemail / tempmail) +// 编辑自定义邮箱服务(支持 moemail / tempmail / duckmail) async function editCustomService(id, subType) { try { const service = await api.get(`/email-services/${id}/full`); - const resolvedSubType = subType || (service.service_type === 'temp_mail' ? 'tempmail' : 'moemail'); + const resolvedSubType = subType || ( + service.service_type === 'temp_mail' + ? 'tempmail' + : service.service_type === 'duck_mail' + ? 'duckmail' + : 'moemail' + ); document.getElementById('edit-custom-id').value = service.id; document.getElementById('edit-custom-name').value = service.name || ''; @@ -551,13 +587,19 @@ async function editCustomService(id, subType) { if (resolvedSubType === 'moemail') { document.getElementById('edit-custom-api-url').value = service.config?.base_url || ''; document.getElementById('edit-custom-api-key').value = ''; - document.getElementById('edit-custom-api-key').placeholder = service.config?.has_api_key ? '已设置,留空保持不变' : 'API Key'; + document.getElementById('edit-custom-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : 'API Key'; document.getElementById('edit-custom-domain').value = service.config?.default_domain || service.config?.domain || ''; - } else { + } else if (resolvedSubType === 'tempmail') { 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 || ''; + } else { + document.getElementById('edit-dm-base-url').value = service.config?.base_url || ''; + document.getElementById('edit-dm-api-key').value = ''; + document.getElementById('edit-dm-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : '请输入 API Key(可选)'; + document.getElementById('edit-dm-domain').value = service.config?.default_domain || ''; + document.getElementById('edit-dm-password-length').value = service.config?.password_length || 12; } elements.editCustomModal.classList.add('active'); @@ -566,7 +608,7 @@ async function editCustomService(id, subType) { } } -// 保存编辑自定义域名服务 +// 保存编辑自定义邮箱服务 async function handleEditCustom(e) { e.preventDefault(); const id = document.getElementById('edit-custom-id').value; @@ -581,7 +623,7 @@ async function handleEditCustom(e) { }; const apiKey = formData.get('api_key'); if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); - } else { + } else if (subType === 'tempmail') { config = { base_url: formData.get('tm_base_url'), domain: formData.get('tm_domain'), @@ -589,6 +631,14 @@ async function handleEditCustom(e) { }; const pwd = formData.get('tm_admin_password'); if (pwd && pwd.trim()) config.admin_password = pwd.trim(); + } else { + config = { + base_url: formData.get('dm_base_url'), + default_domain: formData.get('dm_domain'), + password_length: parseInt(formData.get('dm_password_length'), 10) || 12 + }; + const apiKey = formData.get('dm_api_key'); + if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); } const updateData = { diff --git a/templates/email_services.html b/templates/email_services.html index 00130cf..f80885c 100644 --- a/templates/email_services.html +++ b/templates/email_services.html @@ -42,7 +42,7 @@
0
-
自定义域名
+
自定义邮箱
可用
@@ -93,10 +93,10 @@
- +
-

🔗 自定义域名服务

+

🔗 自定义邮箱服务

@@ -191,11 +191,11 @@
- + + +
@@ -263,11 +283,11 @@
- +