mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-08-08 14:03:44 +08:00
增加duckmail支持
This commit is contained in:
@@ -13,6 +13,8 @@
|
|||||||
- 自定义域名(两种子类型)
|
- 自定义域名(两种子类型)
|
||||||
- **MoeMail**:标准 REST API,配置 API 地址 + API 密钥
|
- **MoeMail**:标准 REST API,配置 API 地址 + API 密钥
|
||||||
- **TempMail**:自部署 Cloudflare Worker 临时邮箱,配置 Worker 地址 + Admin 密码
|
- **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"
|
OUTLOOK = "outlook"
|
||||||
CUSTOM_DOMAIN = "custom_domain"
|
CUSTOM_DOMAIN = "custom_domain"
|
||||||
TEMP_MAIL = "temp_mail"
|
TEMP_MAIL = "temp_mail"
|
||||||
|
DUCK_MAIL = "duck_mail"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -112,6 +113,13 @@ EMAIL_SERVICE_DEFAULTS = {
|
|||||||
"api_key_header": "X-API-Key",
|
"api_key_header": "X-API-Key",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"max_retries": 3,
|
"max_retries": 3,
|
||||||
|
},
|
||||||
|
"duck_mail": {
|
||||||
|
"base_url": "",
|
||||||
|
"default_domain": "",
|
||||||
|
"password_length": 12,
|
||||||
|
"timeout": 30,
|
||||||
|
"max_retries": 3,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ from .tempmail import TempmailService
|
|||||||
from .outlook import OutlookService
|
from .outlook import OutlookService
|
||||||
from .moe_mail import MeoMailEmailService
|
from .moe_mail import MeoMailEmailService
|
||||||
from .temp_mail import TempMailService
|
from .temp_mail import TempMailService
|
||||||
|
from .duck_mail import DuckMailService
|
||||||
|
|
||||||
# 注册服务
|
# 注册服务
|
||||||
EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService)
|
EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService)
|
||||||
EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService)
|
EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService)
|
||||||
EmailServiceFactory.register(EmailServiceType.CUSTOM_DOMAIN, MeoMailEmailService)
|
EmailServiceFactory.register(EmailServiceType.CUSTOM_DOMAIN, MeoMailEmailService)
|
||||||
EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService)
|
EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService)
|
||||||
|
EmailServiceFactory.register(EmailServiceType.DUCK_MAIL, DuckMailService)
|
||||||
|
|
||||||
# 导出 Outlook 模块的额外内容
|
# 导出 Outlook 模块的额外内容
|
||||||
from .outlook.base import (
|
from .outlook.base import (
|
||||||
@@ -50,6 +52,7 @@ __all__ = [
|
|||||||
'OutlookService',
|
'OutlookService',
|
||||||
'MeoMailEmailService',
|
'MeoMailEmailService',
|
||||||
'TempMailService',
|
'TempMailService',
|
||||||
|
'DuckMailService',
|
||||||
# Outlook 模块
|
# Outlook 模块
|
||||||
'ProviderType',
|
'ProviderType',
|
||||||
'EmailMessage',
|
'EmailMessage',
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
@@ -144,6 +144,7 @@ async def get_email_services_stats():
|
|||||||
'outlook_count': 0,
|
'outlook_count': 0,
|
||||||
'custom_count': 0,
|
'custom_count': 0,
|
||||||
'temp_mail_count': 0,
|
'temp_mail_count': 0,
|
||||||
|
'duck_mail_count': 0,
|
||||||
'tempmail_available': True, # 临时邮箱始终可用
|
'tempmail_available': True, # 临时邮箱始终可用
|
||||||
'enabled_count': enabled_count
|
'enabled_count': enabled_count
|
||||||
}
|
}
|
||||||
@@ -155,6 +156,8 @@ async def get_email_services_stats():
|
|||||||
stats['custom_count'] = count
|
stats['custom_count'] = count
|
||||||
elif service_type == 'temp_mail':
|
elif service_type == 'temp_mail':
|
||||||
stats['temp_mail_count'] = count
|
stats['temp_mail_count'] = count
|
||||||
|
elif service_type == 'duck_mail':
|
||||||
|
stats['duck_mail_count'] = count
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
@@ -204,6 +207,17 @@ async def get_service_types():
|
|||||||
{"name": "domain", "label": "邮箱域名", "required": True, "placeholder": "example.com"},
|
{"name": "domain", "label": "邮箱域名", "required": True, "placeholder": "example.com"},
|
||||||
{"name": "enable_prefix", "label": "启用前缀", "required": False, "default": True},
|
{"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:
|
elif service_type == EmailServiceType.TEMP_MAIL:
|
||||||
if 'default_domain' in normalized and 'domain' not in normalized:
|
if 'default_domain' in normalized and 'domain' not in normalized:
|
||||||
normalized['domain'] = normalized.pop('default_domain')
|
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:
|
if proxy_url and 'proxy_url' not in normalized:
|
||||||
normalized['proxy_url'] = proxy_url
|
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}")
|
logger.info(f"使用数据库 Outlook 账户: {selected_service.name}")
|
||||||
else:
|
else:
|
||||||
raise ValueError("所有 Outlook 账户都已注册过 OpenAI 账号,请添加新的 Outlook 账户")
|
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:
|
else:
|
||||||
config = email_service_config or {}
|
config = email_service_config or {}
|
||||||
|
|
||||||
@@ -1069,6 +1086,11 @@ async def get_available_email_services():
|
|||||||
"available": False,
|
"available": False,
|
||||||
"count": 0,
|
"count": 0,
|
||||||
"services": []
|
"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"]["count"] = len(temp_mail_services)
|
||||||
result["temp_mail"]["available"] = len(temp_mail_services) > 0
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ let availableServices = {
|
|||||||
tempmail: { available: true, services: [] },
|
tempmail: { available: true, services: [] },
|
||||||
outlook: { available: false, services: [] },
|
outlook: { available: false, services: [] },
|
||||||
custom_domain: { available: false, services: [] },
|
custom_domain: { available: false, services: [] },
|
||||||
temp_mail: { available: false, services: [] }
|
temp_mail: { available: false, services: [] },
|
||||||
|
duck_mail: { available: false, services: [] }
|
||||||
};
|
};
|
||||||
|
|
||||||
// WebSocket 相关变量
|
// WebSocket 相关变量
|
||||||
@@ -298,7 +299,7 @@ function updateEmailServiceOptions() {
|
|||||||
availableServices.custom_domain.services.forEach(service => {
|
availableServices.custom_domain.services.forEach(service => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = `custom_domain:${service.id || 'default'}`;
|
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';
|
option.dataset.type = 'custom_domain';
|
||||||
if (service.id) {
|
if (service.id) {
|
||||||
option.dataset.serviceId = service.id;
|
option.dataset.serviceId = service.id;
|
||||||
@@ -336,6 +337,23 @@ function updateEmailServiceOptions() {
|
|||||||
|
|
||||||
select.appendChild(optgroup);
|
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;
|
if (!value) return;
|
||||||
|
|
||||||
const [type, id] = value.split(':');
|
const [type, id] = value.split(':');
|
||||||
const selectedOption = e.target.options[e.target.selectedIndex];
|
|
||||||
|
|
||||||
// 处理 Outlook 批量注册模式
|
// 处理 Outlook 批量注册模式
|
||||||
if (type === 'outlook_batch') {
|
if (type === 'outlook_batch') {
|
||||||
isOutlookBatchMode = true;
|
isOutlookBatchMode = true;
|
||||||
@@ -373,6 +389,16 @@ function handleServiceChange(e) {
|
|||||||
if (service) {
|
if (service) {
|
||||||
addLog('info', `[系统] 已选择自定义域名服务: ${service.name}`);
|
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 outlookServices = [];
|
||||||
let customServices = []; // 合并 custom_domain + temp_mail
|
let customServices = []; // 合并 custom_domain + temp_mail + duck_mail
|
||||||
let selectedOutlook = new Set();
|
let selectedOutlook = new Set();
|
||||||
let selectedCustom = new Set();
|
let selectedCustom = new Set();
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ const elements = {
|
|||||||
customSubType: document.getElementById('custom-sub-type'),
|
customSubType: document.getElementById('custom-sub-type'),
|
||||||
addMoemailFields: document.getElementById('add-moemail-fields'),
|
addMoemailFields: document.getElementById('add-moemail-fields'),
|
||||||
addTempmailFields: document.getElementById('add-tempmail-fields'),
|
addTempmailFields: document.getElementById('add-tempmail-fields'),
|
||||||
|
addDuckmailFields: document.getElementById('add-duckmail-fields'),
|
||||||
|
|
||||||
// 编辑自定义域名模态框
|
// 编辑自定义域名模态框
|
||||||
editCustomModal: document.getElementById('edit-custom-modal'),
|
editCustomModal: document.getElementById('edit-custom-modal'),
|
||||||
@@ -58,6 +59,7 @@ const elements = {
|
|||||||
cancelEditCustom: document.getElementById('cancel-edit-custom'),
|
cancelEditCustom: document.getElementById('cancel-edit-custom'),
|
||||||
editMoemailFields: document.getElementById('edit-moemail-fields'),
|
editMoemailFields: document.getElementById('edit-moemail-fields'),
|
||||||
editTempmailFields: document.getElementById('edit-tempmail-fields'),
|
editTempmailFields: document.getElementById('edit-tempmail-fields'),
|
||||||
|
editDuckmailFields: document.getElementById('edit-duckmail-fields'),
|
||||||
editCustomTypeBadge: document.getElementById('edit-custom-type-badge'),
|
editCustomTypeBadge: document.getElementById('edit-custom-type-badge'),
|
||||||
editCustomSubTypeHidden: document.getElementById('edit-custom-sub-type-hidden'),
|
editCustomSubTypeHidden: document.getElementById('edit-custom-sub-type-hidden'),
|
||||||
|
|
||||||
@@ -68,6 +70,12 @@ const elements = {
|
|||||||
cancelEditOutlook: document.getElementById('cancel-edit-outlook'),
|
cancelEditOutlook: document.getElementById('cancel-edit-outlook'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CUSTOM_SUBTYPE_LABELS = {
|
||||||
|
moemail: '🔗 MoeMail(自定义域名 API)',
|
||||||
|
tempmail: '📮 TempMail(自部署 Cloudflare Worker)',
|
||||||
|
duckmail: '🦆 DuckMail(DuckMail API)'
|
||||||
|
};
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadStats();
|
loadStats();
|
||||||
@@ -167,22 +175,18 @@ function closeEmailMoreMenu(el) {
|
|||||||
// 切换添加表单子类型
|
// 切换添加表单子类型
|
||||||
function switchAddSubType(subType) {
|
function switchAddSubType(subType) {
|
||||||
elements.customSubType.value = subType;
|
elements.customSubType.value = subType;
|
||||||
if (subType === 'moemail') {
|
elements.addMoemailFields.style.display = subType === 'moemail' ? '' : 'none';
|
||||||
elements.addMoemailFields.style.display = '';
|
elements.addTempmailFields.style.display = subType === 'tempmail' ? '' : 'none';
|
||||||
elements.addTempmailFields.style.display = 'none';
|
elements.addDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none';
|
||||||
} else {
|
|
||||||
elements.addMoemailFields.style.display = 'none';
|
|
||||||
elements.addTempmailFields.style.display = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换编辑表单子类型显示
|
// 切换编辑表单子类型显示
|
||||||
function switchEditSubType(subType) {
|
function switchEditSubType(subType) {
|
||||||
elements.editCustomSubTypeHidden.value = subType;
|
elements.editCustomSubTypeHidden.value = subType;
|
||||||
const isMoe = subType === 'moemail';
|
elements.editMoemailFields.style.display = subType === 'moemail' ? '' : 'none';
|
||||||
elements.editMoemailFields.style.display = isMoe ? '' : 'none';
|
elements.editTempmailFields.style.display = subType === 'tempmail' ? '' : 'none';
|
||||||
elements.editTempmailFields.style.display = isMoe ? 'none' : '';
|
elements.editDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none';
|
||||||
elements.editCustomTypeBadge.textContent = isMoe ? '🔗 MoeMail(自定义域名 API)' : '📮 TempMail(自部署 Cloudflare Worker)';
|
elements.editCustomTypeBadge.textContent = CUSTOM_SUBTYPE_LABELS[subType] || CUSTOM_SUBTYPE_LABELS.moemail;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载统计信息
|
// 加载统计信息
|
||||||
@@ -190,7 +194,7 @@ async function loadStats() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.get('/email-services/stats');
|
const data = await api.get('/email-services/stats');
|
||||||
elements.outlookCount.textContent = data.outlook_count || 0;
|
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.tempmailStatus.textContent = data.tempmail_available ? '可用' : '不可用';
|
||||||
elements.totalEnabled.textContent = data.enabled_count || 0;
|
elements.totalEnabled.textContent = data.enabled_count || 0;
|
||||||
} catch (error) {
|
} 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() {
|
async function loadCustomServices() {
|
||||||
try {
|
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=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 = [
|
customServices = [
|
||||||
...(r1.services || []).map(s => ({ ...s, _subType: 'moemail' })),
|
...(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) {
|
if (customServices.length === 0) {
|
||||||
@@ -280,7 +305,7 @@ async function loadCustomServices() {
|
|||||||
<td colspan="8">
|
<td colspan="8">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">📭</div>
|
<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 class="empty-state-description">点击「添加服务」按钮创建新服务</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -290,15 +315,12 @@ async function loadCustomServices() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
elements.customTable.innerHTML = customServices.map(service => {
|
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 `
|
return `
|
||||||
<tr data-id="${service.id}">
|
<tr data-id="${service.id}">
|
||||||
<td><input type="checkbox" data-id="${service.id}" ${selectedCustom.has(service.id) ? 'checked' : ''}></td>
|
<td><input type="checkbox" data-id="${service.id}" ${selectedCustom.has(service.id) ? 'checked' : ''}></td>
|
||||||
<td>${escapeHtml(service.name)}</td>
|
<td>${escapeHtml(service.name)}</td>
|
||||||
<td>${typeLabel}</td>
|
<td>${getCustomServiceTypeBadge(service._subType)}</td>
|
||||||
<td style="font-size: 0.75rem;">${escapeHtml(addr)}</td>
|
<td style="font-size: 0.75rem;">${getCustomServiceAddress(service)}</td>
|
||||||
<td title="${service.enabled ? '已启用' : '已禁用'}">${service.enabled ? '✅' : '⭕'}</td>
|
<td title="${service.enabled ? '已启用' : '已禁用'}">${service.enabled ? '✅' : '⭕'}</td>
|
||||||
<td>${service.priority}</td>
|
<td>${service.priority}</td>
|
||||||
<td>${format.date(service.last_used)}</td>
|
<td>${format.date(service.last_used)}</td>
|
||||||
@@ -327,7 +349,7 @@ async function loadCustomServices() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载自定义域名服务失败:', error);
|
console.error('加载自定义邮箱服务失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +404,7 @@ async function handleOutlookImport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加自定义域名服务(根据子类型区分)
|
// 添加自定义邮箱服务(根据子类型区分)
|
||||||
async function handleAddCustom(e) {
|
async function handleAddCustom(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.target);
|
const formData = new FormData(e.target);
|
||||||
@@ -396,7 +418,7 @@ async function handleAddCustom(e) {
|
|||||||
api_key: formData.get('api_key'),
|
api_key: formData.get('api_key'),
|
||||||
default_domain: formData.get('domain')
|
default_domain: formData.get('domain')
|
||||||
};
|
};
|
||||||
} else {
|
} else if (subType === 'tempmail') {
|
||||||
serviceType = 'temp_mail';
|
serviceType = 'temp_mail';
|
||||||
config = {
|
config = {
|
||||||
base_url: formData.get('tm_base_url'),
|
base_url: formData.get('tm_base_url'),
|
||||||
@@ -404,6 +426,14 @@ async function handleAddCustom(e) {
|
|||||||
domain: formData.get('tm_domain'),
|
domain: formData.get('tm_domain'),
|
||||||
enable_prefix: true
|
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 = {
|
const data = {
|
||||||
@@ -535,11 +565,17 @@ function escapeHtml(text) {
|
|||||||
|
|
||||||
// ============== 编辑功能 ==============
|
// ============== 编辑功能 ==============
|
||||||
|
|
||||||
// 编辑自定义域名服务(支持 moemail / tempmail)
|
// 编辑自定义邮箱服务(支持 moemail / tempmail / duckmail)
|
||||||
async function editCustomService(id, subType) {
|
async function editCustomService(id, subType) {
|
||||||
try {
|
try {
|
||||||
const service = await api.get(`/email-services/${id}/full`);
|
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-id').value = service.id;
|
||||||
document.getElementById('edit-custom-name').value = service.name || '';
|
document.getElementById('edit-custom-name').value = service.name || '';
|
||||||
@@ -551,13 +587,19 @@ async function editCustomService(id, subType) {
|
|||||||
if (resolvedSubType === 'moemail') {
|
if (resolvedSubType === 'moemail') {
|
||||||
document.getElementById('edit-custom-api-url').value = service.config?.base_url || '';
|
document.getElementById('edit-custom-api-url').value = service.config?.base_url || '';
|
||||||
document.getElementById('edit-custom-api-key').value = '';
|
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 || '';
|
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-base-url').value = service.config?.base_url || '';
|
||||||
document.getElementById('edit-tm-admin-password').value = '';
|
document.getElementById('edit-tm-admin-password').value = '';
|
||||||
document.getElementById('edit-tm-admin-password').placeholder = service.config?.admin_password ? '已设置,留空保持不变' : '请输入 Admin 密码';
|
document.getElementById('edit-tm-admin-password').placeholder = service.config?.admin_password ? '已设置,留空保持不变' : '请输入 Admin 密码';
|
||||||
document.getElementById('edit-tm-domain').value = service.config?.domain || '';
|
document.getElementById('edit-tm-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');
|
elements.editCustomModal.classList.add('active');
|
||||||
@@ -566,7 +608,7 @@ async function editCustomService(id, subType) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存编辑自定义域名服务
|
// 保存编辑自定义邮箱服务
|
||||||
async function handleEditCustom(e) {
|
async function handleEditCustom(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const id = document.getElementById('edit-custom-id').value;
|
const id = document.getElementById('edit-custom-id').value;
|
||||||
@@ -581,7 +623,7 @@ async function handleEditCustom(e) {
|
|||||||
};
|
};
|
||||||
const apiKey = formData.get('api_key');
|
const apiKey = formData.get('api_key');
|
||||||
if (apiKey && apiKey.trim()) config.api_key = apiKey.trim();
|
if (apiKey && apiKey.trim()) config.api_key = apiKey.trim();
|
||||||
} else {
|
} else if (subType === 'tempmail') {
|
||||||
config = {
|
config = {
|
||||||
base_url: formData.get('tm_base_url'),
|
base_url: formData.get('tm_base_url'),
|
||||||
domain: formData.get('tm_domain'),
|
domain: formData.get('tm_domain'),
|
||||||
@@ -589,6 +631,14 @@ async function handleEditCustom(e) {
|
|||||||
};
|
};
|
||||||
const pwd = formData.get('tm_admin_password');
|
const pwd = formData.get('tm_admin_password');
|
||||||
if (pwd && pwd.trim()) config.admin_password = pwd.trim();
|
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 = {
|
const updateData = {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-card success">
|
<div class="stat-card success">
|
||||||
<div class="stat-value" id="custom-count">0</div>
|
<div class="stat-value" id="custom-count">0</div>
|
||||||
<div class="stat-label">自定义域名</div>
|
<div class="stat-label">自定义邮箱</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card warning">
|
<div class="stat-card warning">
|
||||||
<div class="stat-value" id="tempmail-status">可用</div>
|
<div class="stat-value" id="tempmail-status">可用</div>
|
||||||
@@ -93,10 +93,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 自定义域名管理(含 MoeMail / TempMail) -->
|
<!-- 自定义邮箱管理(含 MoeMail / TempMail / DuckMail) -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>🔗 自定义域名服务</h3>
|
<h3>🔗 自定义邮箱服务</h3>
|
||||||
<button class="btn btn-primary btn-sm" id="add-custom-btn">➕ 添加服务</button>
|
<button class="btn btn-primary btn-sm" id="add-custom-btn">➕ 添加服务</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" style="padding: 0;">
|
<div class="card-body" style="padding: 0;">
|
||||||
@@ -191,11 +191,11 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 添加自定义域名服务模态框(含类型选择) -->
|
<!-- 添加自定义邮箱服务模态框(含类型选择) -->
|
||||||
<div class="modal" id="add-custom-modal">
|
<div class="modal" id="add-custom-modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>➕ 添加自定义域名服务</h3>
|
<h3>➕ 添加自定义邮箱服务</h3>
|
||||||
<button class="modal-close" id="close-custom-modal">×</button>
|
<button class="modal-close" id="close-custom-modal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -209,6 +209,7 @@
|
|||||||
<select id="custom-sub-type" name="sub_type">
|
<select id="custom-sub-type" name="sub_type">
|
||||||
<option value="moemail">MoeMail(自定义域名 API)</option>
|
<option value="moemail">MoeMail(自定义域名 API)</option>
|
||||||
<option value="tempmail">TempMail(自部署 Cloudflare Worker)</option>
|
<option value="tempmail">TempMail(自部署 Cloudflare Worker)</option>
|
||||||
|
<option value="duckmail">DuckMail(DuckMail API)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<!-- MoeMail 字段 -->
|
<!-- MoeMail 字段 -->
|
||||||
@@ -241,6 +242,25 @@
|
|||||||
<input type="text" id="custom-tm-domain" name="tm_domain" placeholder="example.com">
|
<input type="text" id="custom-tm-domain" name="tm_domain" placeholder="example.com">
|
||||||
</div>
|
</div>
|
||||||
</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-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="custom-priority">优先级</label>
|
<label for="custom-priority">优先级</label>
|
||||||
@@ -263,11 +283,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- 编辑自定义域名服务模态框(含类型选择) -->
|
<!-- 编辑自定义邮箱服务模态框(含类型选择) -->
|
||||||
<div class="modal" id="edit-custom-modal">
|
<div class="modal" id="edit-custom-modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>✏️ 编辑自定义域名服务</h3>
|
<h3>✏️ 编辑自定义邮箱服务</h3>
|
||||||
<button class="modal-close" id="close-edit-custom-modal">×</button>
|
<button class="modal-close" id="close-edit-custom-modal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -314,6 +334,26 @@
|
|||||||
<input type="text" id="edit-tm-domain" name="tm_domain" placeholder="example.com">
|
<input type="text" id="edit-tm-domain" name="tm_domain" placeholder="example.com">
|
||||||
</div>
|
</div>
|
||||||
</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-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-custom-priority">优先级</label>
|
<label for="edit-custom-priority">优先级</label>
|
||||||
|
|||||||
@@ -176,10 +176,12 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email-service">邮箱服务</label>
|
<label for="email-service">邮箱服务</label>
|
||||||
<select id="email-service" name="email_service" required>
|
<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="outlook">Outlook</option>
|
||||||
<option value="custom_domain">自定义域名</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
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"
|
||||||
Reference in New Issue
Block a user