合并拉取请求 #33

1、邮箱服务增加duckmail协议;2、解决cpa上传测试成功,但是实际上传失败的问题;3、前端静态资源增加版本号
This commit is contained in:
演变
2026-03-19 19:10:38 +08:00
committed by GitHub
23 changed files with 1562 additions and 98 deletions

View File

@@ -13,6 +13,8 @@
- 自定义域名(两种子类型)
- **MoeMail**:标准 REST API配置 API 地址 + API 密钥
- **TempMail**:自部署 Cloudflare Worker 临时邮箱,配置 Worker 地址 + Admin 密码
- DuckMail
- **DuckMail API**:兼容 DuckMail 接口,手动填写 API 地址、默认域名,可选 API Key
- **注册模式**
- 单次注册

View File

@@ -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`

View File

@@ -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:<id>`
**步骤 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
- 确认编译检查无语法错误

231
llm-api-docs.txt Normal file
View File

@@ -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 <token>
### 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": ["<html>...</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 <your_token>"
# 4. Get message detail
curl https://api.duckmail.sbs/messages/<message_id> \
-H "Authorization: Bearer <your_token>"
# 5. Mark message as read
curl -X PATCH https://api.duckmail.sbs/messages/<message_id> \
-H "Authorization: Bearer <your_token>"
# 6. Get domains (with API key for private domains)
curl https://api.duckmail.sbs/domains \
-H "Authorization: Bearer dk_your_api_key"

View File

@@ -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"]
OUTLOOK_PROVIDER_PRIORITY = ["imap_new", "imap_old", "graph_api"]

View File

@@ -6,6 +6,7 @@ import json
import logging
from typing import List, Dict, Any, Tuple, Optional
from datetime import datetime
from urllib.parse import quote
from curl_cffi import requests as cffi_requests
from curl_cffi import CurlMime
@@ -17,6 +18,77 @@ from ...config.settings import get_settings
logger = logging.getLogger(__name__)
def _normalize_cpa_auth_files_url(api_url: str) -> str:
"""将用户填写的 CPA 地址规范化为 auth-files 接口地址。"""
normalized = (api_url or "").strip().rstrip("/")
lower_url = normalized.lower()
if not normalized:
return ""
if lower_url.endswith("/auth-files"):
return normalized
if lower_url.endswith("/v0/management") or lower_url.endswith("/management"):
return f"{normalized}/auth-files"
if lower_url.endswith("/v0"):
return f"{normalized}/management/auth-files"
return f"{normalized}/v0/management/auth-files"
def _build_cpa_headers(api_token: str, content_type: Optional[str] = None) -> dict:
headers = {
"Authorization": f"Bearer {api_token}",
}
if content_type:
headers["Content-Type"] = content_type
return headers
def _extract_cpa_error(response) -> str:
error_msg = f"上传失败: HTTP {response.status_code}"
try:
error_detail = response.json()
if isinstance(error_detail, dict):
error_msg = error_detail.get("message", error_msg)
except Exception:
error_msg = f"{error_msg} - {response.text[:200]}"
return error_msg
def _post_cpa_auth_file_multipart(upload_url: str, filename: str, file_content: bytes, api_token: str):
mime = CurlMime()
mime.addpart(
name="file",
data=file_content,
filename=filename,
content_type="application/json",
)
return cffi_requests.post(
upload_url,
multipart=mime,
headers=_build_cpa_headers(api_token),
proxies=None,
timeout=30,
impersonate="chrome110",
)
def _post_cpa_auth_file_raw_json(upload_url: str, filename: str, file_content: bytes, api_token: str):
raw_upload_url = f"{upload_url}?name={quote(filename)}"
return cffi_requests.post(
raw_upload_url,
data=file_content,
headers=_build_cpa_headers(api_token, content_type="application/json"),
proxies=None,
timeout=30,
impersonate="chrome110",
)
def generate_token_json(account: Account) -> dict:
"""
生成 CPA 格式的 Token JSON
@@ -73,45 +145,35 @@ def upload_to_cpa(
if not effective_token:
return False, "CPA API Token 未配置"
api_url = effective_url.rstrip("/")
upload_url = f"{api_url}/v0/management/auth-files"
upload_url = _normalize_cpa_auth_files_url(effective_url)
filename = f"{token_data['email']}.json"
file_content = json.dumps(token_data, ensure_ascii=False, indent=2).encode("utf-8")
headers = {
"Authorization": f"Bearer {effective_token}",
}
try:
mime = CurlMime()
mime.addpart(
name="file",
data=file_content,
filename=filename,
content_type="application/json",
)
response = cffi_requests.post(
response = _post_cpa_auth_file_multipart(
upload_url,
multipart=mime,
headers=headers,
proxies=None,
timeout=30,
impersonate="chrome110",
filename,
file_content,
effective_token,
)
if response.status_code in (200, 201):
return True, "上传成功"
error_msg = f"上传失败: HTTP {response.status_code}"
try:
error_detail = response.json()
if isinstance(error_detail, dict):
error_msg = error_detail.get("message", error_msg)
except Exception:
error_msg = f"{error_msg} - {response.text[:200]}"
return False, error_msg
if response.status_code in (404, 405, 415):
logger.warning("CPA multipart 上传失败,尝试原始 JSON 回退: %s", response.status_code)
fallback_response = _post_cpa_auth_file_raw_json(
upload_url,
filename,
file_content,
effective_token,
)
if fallback_response.status_code in (200, 201):
return True, "上传成功"
response = fallback_response
return False, _extract_cpa_error(response)
except Exception as e:
logger.error(f"CPA 上传异常: {e}")
@@ -217,12 +279,11 @@ def test_cpa_connection(api_url: str, api_token: str, proxy: str = None) -> Tupl
if not api_token:
return False, "API Token 不能为空"
api_url = api_url.rstrip("/")
test_url = f"{api_url}/v0/management/auth-files"
headers = {"Authorization": f"Bearer {api_token}"}
test_url = _normalize_cpa_auth_files_url(api_url)
headers = _build_cpa_headers(api_token)
try:
response = cffi_requests.options(
response = cffi_requests.get(
test_url,
headers=headers,
proxies=None,
@@ -230,10 +291,16 @@ def test_cpa_connection(api_url: str, api_token: str, proxy: str = None) -> Tupl
impersonate="chrome110",
)
if response.status_code in (200, 204, 401, 403, 405):
if response.status_code == 401:
return False, "连接成功,但 API Token 无效"
if response.status_code == 200:
return True, "CPA 连接测试成功"
if response.status_code == 401:
return False, "连接成功,但 API Token 无效"
if response.status_code == 403:
return False, "连接成功,但服务端未启用远程管理或当前 Token 无权限"
if response.status_code == 404:
return False, "未找到 CPA auth-files 接口,请检查 API URL 是否填写为根地址、/v0/management 或完整 auth-files 地址"
if response.status_code == 503:
return False, "连接成功,但服务端认证管理器不可用"
return False, f"服务器返回异常状态码: {response.status_code}"

View File

@@ -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',
]
]

366
src/services/duck_mail.py Normal file
View File

@@ -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,
}

View File

@@ -36,6 +36,16 @@ STATIC_DIR = _RESOURCE_ROOT / "static"
TEMPLATES_DIR = _RESOURCE_ROOT / "templates"
def _build_static_asset_version(static_dir: Path) -> str:
"""基于静态文件最后修改时间生成版本号,避免部署后浏览器继续使用旧缓存。"""
latest_mtime = 0
if static_dir.exists():
for path in static_dir.rglob("*"):
if path.is_file():
latest_mtime = max(latest_mtime, int(path.stat().st_mtime))
return str(latest_mtime or 1)
def create_app() -> FastAPI:
"""创建 FastAPI 应用实例"""
settings = get_settings()
@@ -80,6 +90,7 @@ def create_app() -> FastAPI:
# 模板引擎
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
templates.env.globals["static_version"] = _build_static_asset_version(STATIC_DIR)
def _auth_token(password: str) -> str:
secret = get_settings().webui_secret_key.get_secret_value().encode("utf-8")

View File

@@ -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},
]
}
]
}

View File

@@ -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

View File

@@ -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}`);
}
}
}

View File

@@ -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: '🦆 DuckMailDuckMail 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 '<span class="status-badge info">MoeMail</span>';
}
if (subType === 'tempmail') {
return '<span class="status-badge warning">TempMail</span>';
}
return '<span class="status-badge success">DuckMail</span>';
}
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)}<div style="color: var(--text-muted); margin-top: 4px;">默认域名:@${escapeHtml(domain)}</div>`;
}
// 加载自定义邮箱服务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() {
<td colspan="8">
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<div class="empty-state-title">暂无自定义域名服务</div>
<div class="empty-state-title">暂无自定义邮箱服务</div>
<div class="empty-state-description">点击「添加服务」按钮创建新服务</div>
</div>
</td>
@@ -290,15 +315,12 @@ async function loadCustomServices() {
}
elements.customTable.innerHTML = customServices.map(service => {
const isMoe = service._subType === 'moemail';
const typeLabel = isMoe ? '<span class="status-badge info">MoeMail</span>' : '<span class="status-badge warning">TempMail</span>';
const addr = isMoe ? (service.config?.base_url || '-') : (service.config?.base_url || '-');
return `
<tr data-id="${service.id}">
<td><input type="checkbox" data-id="${service.id}" ${selectedCustom.has(service.id) ? 'checked' : ''}></td>
<td>${escapeHtml(service.name)}</td>
<td>${typeLabel}</td>
<td style="font-size: 0.75rem;">${escapeHtml(addr)}</td>
<td>${getCustomServiceTypeBadge(service._subType)}</td>
<td style="font-size: 0.75rem;">${getCustomServiceAddress(service)}</td>
<td title="${service.enabled ? '已启用' : '已禁用'}">${service.enabled ? '✅' : '⭕'}</td>
<td>${service.priority}</td>
<td>${format.date(service.last_used)}</td>
@@ -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 = {

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>账号管理 - OpenAI 注册系统</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ static_version }}">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
<style>
.password-cell {
@@ -257,7 +257,7 @@
</div>
</div>
<script src="/static/js/utils.js"></script>
<script src="/static/js/accounts.js"></script>
<script src="/static/js/utils.js?v={{ static_version }}"></script>
<script src="/static/js/accounts.js?v={{ static_version }}"></script>
</body>
</html>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>邮箱服务 - OpenAI 注册系统</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ static_version }}">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📧</text></svg>">
</head>
<body>
@@ -42,7 +42,7 @@
</div>
<div class="stat-card success">
<div class="stat-value" id="custom-count">0</div>
<div class="stat-label">自定义域名</div>
<div class="stat-label">自定义邮箱</div>
</div>
<div class="stat-card warning">
<div class="stat-value" id="tempmail-status">可用</div>
@@ -93,10 +93,10 @@
</div>
</div>
<!-- 自定义域名管理(含 MoeMail / TempMail -->
<!-- 自定义邮箱管理(含 MoeMail / TempMail / DuckMail -->
<div class="card">
<div class="card-header">
<h3>🔗 自定义域名服务</h3>
<h3>🔗 自定义邮箱服务</h3>
<button class="btn btn-primary btn-sm" id="add-custom-btn"> 添加服务</button>
</div>
<div class="card-body" style="padding: 0;">
@@ -191,11 +191,11 @@
</main>
</div>
<!-- 添加自定义域名服务模态框(含类型选择) -->
<!-- 添加自定义邮箱服务模态框(含类型选择) -->
<div class="modal" id="add-custom-modal">
<div class="modal-content">
<div class="modal-header">
<h3> 添加自定义域名服务</h3>
<h3> 添加自定义邮箱服务</h3>
<button class="modal-close" id="close-custom-modal">&times;</button>
</div>
<div class="modal-body">
@@ -209,6 +209,7 @@
<select id="custom-sub-type" name="sub_type">
<option value="moemail">MoeMail自定义域名 API</option>
<option value="tempmail">TempMail自部署 Cloudflare Worker</option>
<option value="duckmail">DuckMailDuckMail API</option>
</select>
</div>
<!-- MoeMail 字段 -->
@@ -241,6 +242,25 @@
<input type="text" id="custom-tm-domain" name="tm_domain" placeholder="example.com">
</div>
</div>
<!-- DuckMail 字段 -->
<div id="add-duckmail-fields" style="display:none;">
<div class="form-group">
<label for="custom-dm-base-url">API 地址</label>
<input type="text" id="custom-dm-base-url" name="dm_base_url" placeholder="https://api.duckmail.sbs">
</div>
<div class="form-group">
<label for="custom-dm-api-key">API Key可选</label>
<input type="text" id="custom-dm-api-key" name="dm_api_key" placeholder="dk_xxx">
</div>
<div class="form-group">
<label for="custom-dm-domain">默认域名</label>
<input type="text" id="custom-dm-domain" name="dm_domain" placeholder="example.com">
</div>
<div class="form-group">
<label for="custom-dm-password-length">随机密码长度</label>
<input type="number" id="custom-dm-password-length" name="dm_password_length" min="6" max="64" value="12">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="custom-priority">优先级</label>
@@ -263,11 +283,11 @@
</div>
<!-- 编辑自定义域名服务模态框(含类型选择) -->
<!-- 编辑自定义邮箱服务模态框(含类型选择) -->
<div class="modal" id="edit-custom-modal">
<div class="modal-content">
<div class="modal-header">
<h3>✏️ 编辑自定义域名服务</h3>
<h3>✏️ 编辑自定义邮箱服务</h3>
<button class="modal-close" id="close-edit-custom-modal">&times;</button>
</div>
<div class="modal-body">
@@ -314,6 +334,26 @@
<input type="text" id="edit-tm-domain" name="tm_domain" placeholder="example.com">
</div>
</div>
<!-- DuckMail 字段 -->
<div id="edit-duckmail-fields" style="display:none;">
<div class="form-group">
<label for="edit-dm-base-url">API 地址</label>
<input type="text" id="edit-dm-base-url" name="dm_base_url" placeholder="https://api.duckmail.sbs">
</div>
<div class="form-group">
<label for="edit-dm-api-key">API Key</label>
<input type="text" id="edit-dm-api-key" name="dm_api_key" placeholder="留空则不修改">
<small style="color: var(--text-muted);">留空则保持原值不变</small>
</div>
<div class="form-group">
<label for="edit-dm-domain">默认域名</label>
<input type="text" id="edit-dm-domain" name="dm_domain" placeholder="example.com">
</div>
<div class="form-group">
<label for="edit-dm-password-length">随机密码长度</label>
<input type="number" id="edit-dm-password-length" name="dm_password_length" min="6" max="64" value="12">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="edit-custom-priority">优先级</label>
@@ -385,7 +425,7 @@
</div>
<script src="/static/js/utils.js"></script>
<script src="/static/js/email_services.js"></script>
<script src="/static/js/utils.js?v={{ static_version }}"></script>
<script src="/static/js/email_services.js?v={{ static_version }}"></script>
</body>
</html>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>注册控制台 - OpenAI 注册系统</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ static_version }}">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>">
<style>
/* 两栏布局 */
@@ -176,10 +176,12 @@
<div class="form-group">
<label for="email-service">邮箱服务</label>
<select id="email-service" name="email_service" required>
<option value="tempmail">Tempmail.lol (临时邮箱)</option>
<option value="tempmail:default">Tempmail.lol (临时邮箱)</option>
<option value="outlook">Outlook</option>
<option value="custom_domain">自定义域名</option>
<option value="outlook_batch">Outlook 批量注册</option>
<option value="temp_mail">Temp-Mail 自部署</option>
<option value="duck_mail">DuckMail</option>
<option value="outlook_batch:all">Outlook 批量注册</option>
</select>
</div>
@@ -407,7 +409,7 @@
</main>
</div>
<script src="/static/js/utils.js"></script>
<script src="/static/js/app.js"></script>
<script src="/static/js/utils.js?v={{ static_version }}"></script>
<script src="/static/js/app.js?v={{ static_version }}"></script>
</body>
</html>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>访问验证 - OpenAI 注册系统</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ static_version }}">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔒</text></svg>">
<style>
.login-wrap {

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>支付升级 - OpenAI 注册系统</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ static_version }}">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>💳</text></svg>">
<style>
.plan-cards {
@@ -166,7 +166,7 @@
</main>
</div>
<script src="/static/js/utils.js"></script>
<script src="/static/js/payment.js"></script>
<script src="/static/js/utils.js?v={{ static_version }}"></script>
<script src="/static/js/payment.js?v={{ static_version }}"></script>
</body>
</html>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统设置 - OpenAI 注册系统</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ static_version }}">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚙️</text></svg>">
</head>
<body>
@@ -389,6 +389,7 @@
<div class="form-group">
<label for="cpa-service-url">API URL *</label>
<input type="text" id="cpa-service-url" placeholder="https://cpa.example.com" required>
<p class="hint">支持填写根地址、`/v0/management` 或完整的 `/v0/management/auth-files` 地址</p>
</div>
<div class="form-group">
<label for="cpa-service-token">API Token</label>
@@ -568,7 +569,7 @@
</main>
</div>
<script src="/static/js/utils.js"></script>
<script src="/static/js/settings.js"></script>
<script src="/static/js/utils.js?v={{ static_version }}"></script>
<script src="/static/js/settings.js?v={{ static_version }}"></script>
</body>
</html>

110
tests/test_cpa_upload.py Normal file
View File

@@ -0,0 +1,110 @@
from src.core.upload import cpa_upload
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text
def json(self):
if self._payload is None:
raise ValueError("no json payload")
return self._payload
class FakeMime:
def __init__(self):
self.parts = []
def addpart(self, **kwargs):
self.parts.append(kwargs)
def test_upload_to_cpa_accepts_management_root_url(monkeypatch):
calls = []
def fake_post(url, **kwargs):
calls.append({"url": url, "kwargs": kwargs})
return FakeResponse(status_code=201)
monkeypatch.setattr(cpa_upload, "CurlMime", FakeMime)
monkeypatch.setattr(cpa_upload.cffi_requests, "post", fake_post)
success, message = cpa_upload.upload_to_cpa(
{"email": "tester@example.com"},
api_url="https://cpa.example.com/v0/management",
api_token="token-123",
)
assert success is True
assert message == "上传成功"
assert calls[0]["url"] == "https://cpa.example.com/v0/management/auth-files"
def test_upload_to_cpa_does_not_double_append_full_endpoint(monkeypatch):
calls = []
def fake_post(url, **kwargs):
calls.append({"url": url, "kwargs": kwargs})
return FakeResponse(status_code=201)
monkeypatch.setattr(cpa_upload, "CurlMime", FakeMime)
monkeypatch.setattr(cpa_upload.cffi_requests, "post", fake_post)
success, _ = cpa_upload.upload_to_cpa(
{"email": "tester@example.com"},
api_url="https://cpa.example.com/v0/management/auth-files",
api_token="token-123",
)
assert success is True
assert calls[0]["url"] == "https://cpa.example.com/v0/management/auth-files"
def test_upload_to_cpa_falls_back_to_raw_json_when_multipart_returns_404(monkeypatch):
calls = []
responses = [
FakeResponse(status_code=404, text="404 page not found"),
FakeResponse(status_code=200, payload={"status": "ok"}),
]
def fake_post(url, **kwargs):
calls.append({"url": url, "kwargs": kwargs})
return responses.pop(0)
monkeypatch.setattr(cpa_upload, "CurlMime", FakeMime)
monkeypatch.setattr(cpa_upload.cffi_requests, "post", fake_post)
success, message = cpa_upload.upload_to_cpa(
{"email": "tester@example.com", "type": "codex"},
api_url="https://cpa.example.com",
api_token="token-123",
)
assert success is True
assert message == "上传成功"
assert calls[0]["kwargs"]["multipart"] is not None
assert calls[1]["url"] == "https://cpa.example.com/v0/management/auth-files?name=tester%40example.com.json"
assert calls[1]["kwargs"]["headers"]["Content-Type"] == "application/json"
assert calls[1]["kwargs"]["data"].startswith(b"{")
def test_test_cpa_connection_uses_get_and_normalized_url(monkeypatch):
calls = []
def fake_get(url, **kwargs):
calls.append({"url": url, "kwargs": kwargs})
return FakeResponse(status_code=200, payload={"files": []})
monkeypatch.setattr(cpa_upload.cffi_requests, "get", fake_get)
success, message = cpa_upload.test_cpa_connection(
"https://cpa.example.com/v0/management",
"token-123",
)
assert success is True
assert message == "CPA 连接测试成功"
assert calls[0]["url"] == "https://cpa.example.com/v0/management/auth-files"
assert calls[0]["kwargs"]["headers"]["Authorization"] == "Bearer token-123"

View File

@@ -0,0 +1,143 @@
from src.services.duck_mail import DuckMailService
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text
self.headers = {}
def json(self):
if self._payload is None:
raise ValueError("no json payload")
return self._payload
class FakeHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def request(self, method, url, **kwargs):
self.calls.append({
"method": method,
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"未准备响应: {method} {url}")
return self.responses.pop(0)
def test_create_email_creates_account_and_fetches_token():
service = DuckMailService({
"base_url": "https://api.duckmail.test",
"default_domain": "duckmail.sbs",
"api_key": "dk_test_key",
"password_length": 10,
})
fake_client = FakeHTTPClient([
FakeResponse(
status_code=201,
payload={
"id": "account-1",
"address": "tester@duckmail.sbs",
"authType": "email",
},
),
FakeResponse(
payload={
"id": "account-1",
"token": "token-123",
}
),
])
service.http_client = fake_client
email_info = service.create_email()
assert email_info["email"] == "tester@duckmail.sbs"
assert email_info["service_id"] == "account-1"
assert email_info["account_id"] == "account-1"
assert email_info["token"] == "token-123"
create_call = fake_client.calls[0]
assert create_call["method"] == "POST"
assert create_call["url"] == "https://api.duckmail.test/accounts"
assert create_call["kwargs"]["json"]["address"].endswith("@duckmail.sbs")
assert len(create_call["kwargs"]["json"]["password"]) == 10
assert create_call["kwargs"]["headers"]["Authorization"] == "Bearer dk_test_key"
token_call = fake_client.calls[1]
assert token_call["method"] == "POST"
assert token_call["url"] == "https://api.duckmail.test/token"
assert token_call["kwargs"]["json"] == {
"address": "tester@duckmail.sbs",
"password": email_info["password"],
}
def test_get_verification_code_reads_message_detail_and_extracts_code():
service = DuckMailService({
"base_url": "https://api.duckmail.test",
"default_domain": "duckmail.sbs",
})
fake_client = FakeHTTPClient([
FakeResponse(
status_code=201,
payload={
"id": "account-1",
"address": "tester@duckmail.sbs",
"authType": "email",
},
),
FakeResponse(
payload={
"id": "account-1",
"token": "token-123",
}
),
FakeResponse(
payload={
"hydra:member": [
{
"id": "msg-1",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:00Z",
}
]
}
),
FakeResponse(
payload={
"id": "msg-1",
"text": "Your OpenAI verification code is 654321",
"html": [],
}
),
])
service.http_client = fake_client
email_info = service.create_email()
code = service.get_verification_code(
email=email_info["email"],
email_id=email_info["service_id"],
timeout=1,
)
assert code == "654321"
messages_call = fake_client.calls[2]
assert messages_call["method"] == "GET"
assert messages_call["url"] == "https://api.duckmail.test/messages"
assert messages_call["kwargs"]["headers"]["Authorization"] == "Bearer token-123"
detail_call = fake_client.calls[3]
assert detail_call["method"] == "GET"
assert detail_call["url"] == "https://api.duckmail.test/messages/msg-1"
assert detail_call["kwargs"]["headers"]["Authorization"] == "Bearer token-123"

View File

@@ -0,0 +1,94 @@
import asyncio
from contextlib import contextmanager
from pathlib import Path
from src.config.constants import EmailServiceType
from src.database.models import Base, EmailService
from src.database.session import DatabaseSessionManager
from src.services.base import EmailServiceFactory
from src.web.routes import email as email_routes
from src.web.routes import registration as registration_routes
class DummySettings:
custom_domain_base_url = ""
custom_domain_api_key = None
def test_duck_mail_service_registered():
service_type = EmailServiceType("duck_mail")
service_class = EmailServiceFactory.get_service_class(service_type)
assert service_class is not None
assert service_class.__name__ == "DuckMailService"
def test_email_service_types_include_duck_mail():
result = asyncio.run(email_routes.get_service_types())
duckmail_type = next(item for item in result["types"] if item["value"] == "duck_mail")
assert duckmail_type["label"] == "DuckMail"
field_names = [field["name"] for field in duckmail_type["config_fields"]]
assert "base_url" in field_names
assert "default_domain" in field_names
assert "api_key" in field_names
def test_filter_sensitive_config_marks_duckmail_api_key():
filtered = email_routes.filter_sensitive_config({
"base_url": "https://api.duckmail.test",
"api_key": "dk_test_key",
"default_domain": "duckmail.sbs",
})
assert filtered["base_url"] == "https://api.duckmail.test"
assert filtered["default_domain"] == "duckmail.sbs"
assert filtered["has_api_key"] is True
assert "api_key" not in filtered
def test_registration_available_services_include_duck_mail(monkeypatch):
runtime_dir = Path("tests_runtime")
runtime_dir.mkdir(exist_ok=True)
db_path = runtime_dir / "duckmail_routes.db"
if db_path.exists():
db_path.unlink()
manager = DatabaseSessionManager(f"sqlite:///{db_path}")
Base.metadata.create_all(bind=manager.engine)
with manager.session_scope() as session:
session.add(
EmailService(
service_type="duck_mail",
name="DuckMail 主服务",
config={
"base_url": "https://api.duckmail.test",
"default_domain": "duckmail.sbs",
"api_key": "dk_test_key",
},
enabled=True,
priority=0,
)
)
@contextmanager
def fake_get_db():
session = manager.SessionLocal()
try:
yield session
finally:
session.close()
monkeypatch.setattr(registration_routes, "get_db", fake_get_db)
import src.config.settings as settings_module
monkeypatch.setattr(settings_module, "get_settings", lambda: DummySettings())
result = asyncio.run(registration_routes.get_available_email_services())
assert result["duck_mail"]["available"] is True
assert result["duck_mail"]["count"] == 1
assert result["duck_mail"]["services"][0]["name"] == "DuckMail 主服务"
assert result["duck_mail"]["services"][0]["type"] == "duck_mail"
assert result["duck_mail"]["services"][0]["default_domain"] == "duckmail.sbs"

View File

@@ -0,0 +1,28 @@
from pathlib import Path
import importlib
web_app = importlib.import_module("src.web.app")
def test_static_asset_version_is_non_empty_string():
version = web_app._build_static_asset_version(web_app.STATIC_DIR)
assert isinstance(version, str)
assert version
assert version.isdigit()
def test_email_services_template_uses_versioned_static_assets():
template = Path("templates/email_services.html").read_text(encoding="utf-8")
assert '/static/css/style.css?v={{ static_version }}' in template
assert '/static/js/utils.js?v={{ static_version }}' in template
assert '/static/js/email_services.js?v={{ static_version }}' in template
def test_index_template_uses_versioned_static_assets():
template = Path("templates/index.html").read_text(encoding="utf-8")
assert '/static/css/style.css?v={{ static_version }}' in template
assert '/static/js/utils.js?v={{ static_version }}' in template
assert '/static/js/app.js?v={{ static_version }}' in template