mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-05-07 08:02:51 +08:00
@@ -13,6 +13,8 @@
|
||||
- 自定义域名(两种子类型)
|
||||
- **MoeMail**:标准 REST API,配置 API 地址 + API 密钥
|
||||
- **TempMail**:自部署 Cloudflare Worker 临时邮箱,配置 Worker 地址 + Admin 密码
|
||||
- DuckMail
|
||||
- **DuckMail API**:兼容 DuckMail 接口,手动填写 API 地址、默认域名,可选 API Key
|
||||
|
||||
- **注册模式**
|
||||
- 单次注册
|
||||
|
||||
77
docs/plans/2026-03-19-duckmail-design.md
Normal file
77
docs/plans/2026-03-19-duckmail-design.md
Normal 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`
|
||||
151
docs/plans/2026-03-19-duckmail-implementation.md
Normal file
151
docs/plans/2026-03-19-duckmail-implementation.md
Normal 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
231
llm-api-docs.txt
Normal 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"
|
||||
@@ -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"]
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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
366
src/services/duck_mail.py
Normal 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,
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '<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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">×</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">DuckMail(DuckMail 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">×</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
110
tests/test_cpa_upload.py
Normal 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"
|
||||
143
tests/test_duck_mail_service.py
Normal file
143
tests/test_duck_mail_service.py
Normal 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"
|
||||
94
tests/test_email_service_duckmail_routes.py
Normal file
94
tests/test_email_service_duckmail_routes.py
Normal 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"
|
||||
28
tests/test_static_asset_versioning.py
Normal file
28
tests/test_static_asset_versioning.py
Normal 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
|
||||
Reference in New Issue
Block a user