mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-06-25 17:35:07 +08:00
feat: add agent-mail skill, parsed mail API and docs (#993)
* feat: add cf-temp-mail-usage skill and parsed mail API for AI agents - feat: new /api/parsed_mails and /api/parsed_mail/:id endpoints returning server-parsed subject/text/html/attachments metadata (reuses commonParseMail) - feat: add .claude/skills/cf-temp-mail-usage read-only skill so AI agents (OpenClaw / Codex / Cursor) can consume a mailbox with a user-supplied JWT, bypassing the Turnstile challenge required for mailbox creation - refactor: split mails_api/index.ts and admin_api/index.ts into thin route shells; move business logic into dedicated *_api.ts files - docs: update README / README_EN / CHANGELOG with agent-email feature and npx degit install instructions for the skill Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: rename skill to cf-temp-mail-agent-mail, add agent-email docs, fix sender trim - Rename skill from cf-temp-mail-usage to cf-temp-mail-agent-mail - Rewrite SKILL.md: parsed API primary, local fallback, prerequisites, multi-agent install - Add vitepress docs (zh + en) for AI Agent mailbox usage - Fix leading space in parsed_mail_api sender field via .trim() - Update README install section with 3 install methods - Update changelogs (zh + en) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: simplify README agent skill section to one-liner with links Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: add send mail API to skill, credential persistence, remove poll example Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
198
.claude/skills/cf-temp-mail-agent-mail/SKILL.md
Normal file
198
.claude/skills/cf-temp-mail-agent-mail/SKILL.md
Normal file
@@ -0,0 +1,198 @@
|
||||
---
|
||||
name: cf-temp-mail-agent-mail
|
||||
description: Read and send mails from a cloudflare_temp_email mailbox using a user-supplied Address JWT and API base URL. Use when the user (or an agent such as OpenClaw / Codex / Cursor) needs to list the inbox, fetch a specific message, or send an email via the server-parsed /api/parsed_mails, /api/parsed_mail/:id, and /api/send_mail endpoints. Falls back to local parsing of /api/mail/:id raw source with mail-parser-wasm + postal-mime if the parsed endpoints are unavailable. Does NOT handle mailbox creation — the user provides the JWT themselves.
|
||||
---
|
||||
|
||||
# Temp-Mail Agent Usage
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The user must first **open the frontend** (e.g. `https://mail.example.com`) in a browser and create or log into a mailbox address. This step may require passing a Turnstile CAPTCHA that agents cannot complete. After that, the **Address JWT** is displayed in the frontend UI and can be copied directly.
|
||||
|
||||
## Inputs the user must provide
|
||||
|
||||
- `BASE` — API base URL, e.g. `https://mail.example.com`.
|
||||
- `JWT` — Address JWT, visible and copyable from the frontend UI after creating or logging into a mailbox.
|
||||
- *(optional)* `SITE_PASSWORD` — only if the deployment enabled `x-custom-auth`.
|
||||
|
||||
If anything is missing, ask the user before making requests.
|
||||
|
||||
## Credential persistence
|
||||
|
||||
To avoid asking every time, save credentials to `~/.cf-temp-mail/credentials.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"base": "https://mail.example.com",
|
||||
"jwt": "<ADDRESS_JWT>",
|
||||
"site_password": ""
|
||||
}
|
||||
```
|
||||
|
||||
On first use, if the file exists, read and use it. If not, ask the user and save for next time. Before each request, validate the JWT via `GET /api/settings` — if it returns `401`, inform the user the JWT is expired and ask for a fresh one, then update the file.
|
||||
|
||||
## Required headers
|
||||
|
||||
- `Authorization: Bearer <JWT>` — on every `/api/*` request.
|
||||
- `x-custom-auth: <SITE_PASSWORD>` — only when the site requires it.
|
||||
- `x-lang: en` or `zh` — optional, error-message language.
|
||||
|
||||
Do not send the Address JWT as `x-user-token` — that is a different JWT type and will yield `401 InvalidAddressCredentialMsg`.
|
||||
|
||||
## Primary path: parsed endpoints
|
||||
|
||||
| Task | Method | Path | Returns |
|
||||
| ------------------- | ------ | ---------------------------------- | ----------------------------------------- |
|
||||
| Address info | GET | `/api/settings` | `{ address, send_balance }` |
|
||||
| List parsed mails | GET | `/api/parsed_mails?limit=&offset=` | `{ results: [parsedMail], count }` |
|
||||
| Get one parsed mail | GET | `/api/parsed_mail/:id` | `parsedMail` |
|
||||
|
||||
`limit` 1–100, `offset` 0-based. On `429`, back off.
|
||||
|
||||
`parsedMail` shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"message_id": "<...>",
|
||||
"source": "noreply@foo.com",
|
||||
"to": "abc@yourdomain.com",
|
||||
"created_at": "2026-04-21 10:00:00",
|
||||
"sender": "Foo <noreply@foo.com>",
|
||||
"subject": "Your code is 123456",
|
||||
"text": "Your code is 123456\n",
|
||||
"html": "<p>Your code is <b>123456</b></p>",
|
||||
"attachments": [
|
||||
{ "filename": "a.pdf", "mimeType": "application/pdf", "disposition": "attachment", "size": 12345 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Attachments carry metadata only; no binary content.
|
||||
|
||||
### 1. Smoke-test the JWT
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/settings" -H "Authorization: Bearer $JWT"
|
||||
# → { "address": "abc123@example.com", "send_balance": 0 }
|
||||
```
|
||||
|
||||
If this returns `401`, JWT is wrong / expired / mismatched with `BASE` — ask the user for a fresh one.
|
||||
|
||||
### 2. List the inbox
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/parsed_mails?limit=20&offset=0" \
|
||||
-H "Authorization: Bearer $JWT"
|
||||
```
|
||||
|
||||
### 3. Get one mail
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/parsed_mail/<id>" -H "Authorization: Bearer $JWT"
|
||||
```
|
||||
|
||||
## Send mail
|
||||
|
||||
Requires `send_balance > 0` (check via `/api/settings`). The deployment must have a send method configured (Resend / SMTP / Cloudflare Email Routing binding).
|
||||
|
||||
| Task | Method | Path | Body / Returns |
|
||||
| ----------------------- | ------ | ------------------------------- | ------------------------------------------------- |
|
||||
| Request send access | POST | `/api/request_send_mail_access` | `{}` → `{ status: "ok" }` |
|
||||
| Send mail | POST | `/api/send_mail` | `sendMailBody` → `{ status: "ok" }` |
|
||||
| List sent (sendbox) | GET | `/api/sendbox?limit=&offset=` | `{ results: [...], count }` |
|
||||
| Delete sent item | DELETE | `/api/sendbox/:id` | `{ success: true }` |
|
||||
|
||||
`sendMailBody`:
|
||||
|
||||
```json
|
||||
{
|
||||
"from_name": "My Name",
|
||||
"to_mail": "recipient@example.com",
|
||||
"to_name": "Recipient",
|
||||
"subject": "Hello",
|
||||
"content": "<p>Hi</p>",
|
||||
"is_html": true
|
||||
}
|
||||
```
|
||||
|
||||
`from_name` and `to_name` are optional (can be empty string). `is_html: false` sends plain text.
|
||||
|
||||
### Send example
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/send_mail" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"from_name":"","to_mail":"someone@example.com","to_name":"","subject":"Test","content":"Hello","is_html":false}'
|
||||
```
|
||||
|
||||
## Fallback: local parse of raw source
|
||||
|
||||
If `/api/parsed_mails` / `/api/parsed_mail/:id` returns `404` (older deployment) or a parse error, fall back to `/api/mails` / `/api/mail/:id` (RFC822 `raw`) and parse locally. Mirror the frontend strategy in `frontend/src/utils/email-parser.js`: try **`mail-parser-wasm`** first, fall back to **`postal-mime`**.
|
||||
|
||||
```bash
|
||||
npm i mail-parser-wasm postal-mime
|
||||
```
|
||||
|
||||
```js
|
||||
// parseRaw.mjs — drop-in parser matching frontend behavior
|
||||
async function parseRaw(raw) {
|
||||
try {
|
||||
const { parse_message } = await import('mail-parser-wasm');
|
||||
const m = parse_message(raw);
|
||||
if (m?.subject && (m?.body_html || m?.text)) {
|
||||
return {
|
||||
sender: m.sender || '',
|
||||
subject: m.subject || '',
|
||||
text: m.text || '',
|
||||
html: m.body_html || '',
|
||||
attachments: (m.attachments || []).map(a => ({
|
||||
filename: a.filename || a.content_id || '',
|
||||
mimeType: a.content_type || '',
|
||||
size: a.content?.length ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
const PostalMime = (await import('postal-mime')).default;
|
||||
const p = await PostalMime.parse(raw);
|
||||
const sender = p.from?.name && p.from?.address
|
||||
? `${p.from.name} <${p.from.address}>`
|
||||
: (p.from?.address || '');
|
||||
return {
|
||||
sender,
|
||||
subject: p.subject || '',
|
||||
text: p.text || '',
|
||||
html: p.html || '',
|
||||
attachments: (p.attachments || []).map(a => ({
|
||||
filename: a.filename || a.contentId || '',
|
||||
mimeType: a.mimeType || '',
|
||||
size: a.content?.length ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// usage
|
||||
const row = await (await fetch(`${BASE}/api/mail/${id}`, {
|
||||
headers: { Authorization: `Bearer ${JWT}` },
|
||||
})).json();
|
||||
const parsed = await parseRaw(row.raw);
|
||||
```
|
||||
|
||||
For attachment bytes, use `postal-mime` directly — `parsed.attachments[i].content` is a `Uint8Array`.
|
||||
|
||||
## Polling discipline
|
||||
|
||||
- Start at `poll=3s`, exponential backoff capped at 10s.
|
||||
- Dedupe by mail `id`.
|
||||
- Never poll faster than once per second.
|
||||
- Respect `429` — sleep and retry.
|
||||
|
||||
## Common errors
|
||||
|
||||
- `401 InvalidAddressCredentialMsg` — JWT wrong/expired/sent via wrong header. Ask the user for a fresh JWT.
|
||||
- `401 CustomAuthPasswordMsg` — site requires `x-custom-auth`; attach `SITE_PASSWORD`.
|
||||
- `400 InvalidLimitMsg` / `InvalidOffsetMsg` — `limit` must be 1..100, `offset ≥ 0`.
|
||||
- `404` on `/api/parsed_mail*` — deployment predates the parsed endpoints; use the fallback.
|
||||
- `429` — rate limited; back off.
|
||||
@@ -10,10 +10,16 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |API| 新增服务端解析邮件接口 `/api/parsed_mails` 与 `/api/parsed_mail/:id`,直接返回 `sender` / `subject` / `text` / `html` / `attachments` 元信息(复用 `commonParseMail`),AI agent 侧不再需要引入 MIME 解析器
|
||||
- feat: |Skill| 新增仓库内置只读 skill `cf-temp-mail-agent-mail`(`.claude/skills/cf-temp-mail-agent-mail/`),让 OpenClaw / Codex / Cursor 等 AI agent 凭用户提供的 Address JWT + API 地址读取邮箱、轮询验证码,绕开创建邮箱时的 Turnstile 人机验证;可通过 `npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-agent-mail` 安装
|
||||
- docs: |文档| 新增"AI Agent 使用邮箱"文档(`guide/feature/agent-email`),说明 `parsed_mail` API 用法,并在 parsed API 不可用时给出对齐前端的 `mail-parser-wasm` + `postal-mime` 本地解析回退方案
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
### Improvements
|
||||
|
||||
- refactor: |Worker| 拆分 `mails_api/index.ts` 与 `admin_api/index.ts`,入口只负责挂路由,业务拆到各自的 `*_api.ts` 文件(`mails_crud.ts` / `new_address.ts` / `parsed_mail_api.ts` / `address_api.ts` / `address_sender_api.ts` / `sendbox_api.ts` / `statistics_api.ts` / `account_settings_api.ts`),保持路径与行为不变
|
||||
|
||||
## v1.7.0(main)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -10,10 +10,16 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |API| Add server-side parsed-mail endpoints `/api/parsed_mails` and `/api/parsed_mail/:id` that return `sender` / `subject` / `text` / `html` / `attachments` metadata directly (reuses `commonParseMail`), so AI agents no longer need a client-side MIME parser
|
||||
- feat: |Skill| Bundle a read-only skill `cf-temp-mail-agent-mail` (`.claude/skills/cf-temp-mail-agent-mail/`) so AI agents like OpenClaw / Codex / Cursor can consume a mailbox with a user-supplied Address JWT + API base URL — list mails, poll verification codes, etc. — sidestepping the Turnstile challenge required to create a mailbox. Install via `npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-agent-mail`
|
||||
- docs: |Docs| Add "AI Agent Mailbox Usage" doc (`guide/feature/agent-email`) covering the `parsed_mail` API and a local-parse fallback using `mail-parser-wasm` + `postal-mime` (mirrors the frontend) when parsed endpoints are unavailable
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
### Improvements
|
||||
|
||||
- refactor: |Worker| Split `mails_api/index.ts` and `admin_api/index.ts` so the index files only wire routes. Business logic moved into dedicated `*_api.ts` files (`mails_crud.ts` / `new_address.ts` / `parsed_mail_api.ts` / `address_api.ts` / `address_sender_api.ts` / `sendbox_api.ts` / `statistics_api.ts` / `account_settings_api.ts`). Paths and behavior unchanged
|
||||
|
||||
## v1.7.0(main)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
- [x] Webhook 支持,消息推送集成
|
||||
- [x] 支持 `CF Turnstile` 人机验证
|
||||
- [x] 限流配置,防止滥用
|
||||
- [x] **Agent 友好**:内置 [`cf-temp-mail-agent-mail`](.claude/skills/cf-temp-mail-agent-mail/SKILL.md) skill,AI agent 可直接消费邮箱,详见 [文档](vitepress-docs/docs/zh/guide/feature/agent-email.md)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
- [x] Webhook support and message push integration
|
||||
- [x] Support `CF Turnstile` CAPTCHA verification
|
||||
- [x] Rate limiting configuration to prevent abuse
|
||||
- [x] **Agent-friendly**: bundled [`cf-temp-mail-agent-mail`](.claude/skills/cf-temp-mail-agent-mail/SKILL.md) skill lets AI agents consume a mailbox directly, see [docs](vitepress-docs/docs/en/guide/feature/agent-email.md)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -171,6 +171,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
items: [
|
||||
{ text: 'New Address API', link: 'feature/new-address-api' },
|
||||
{ text: 'View Email API', link: 'feature/mail-api' },
|
||||
{ text: 'AI Agent Mailbox Usage', link: 'feature/agent-email' },
|
||||
{ text: 'Send Email API', link: 'feature/send-mail-api' },
|
||||
{ text: 'Delete Address API', link: 'feature/delete-address' },
|
||||
]
|
||||
|
||||
@@ -171,6 +171,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
items: [
|
||||
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
|
||||
{ text: '查看邮件 API', link: 'feature/mail-api' },
|
||||
{ text: 'AI Agent 使用邮箱', link: 'feature/agent-email' },
|
||||
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
|
||||
{ text: '删除邮箱地址 API', link: 'feature/delete-address' },
|
||||
]
|
||||
|
||||
212
vitepress-docs/docs/en/guide/feature/agent-email.md
Normal file
212
vitepress-docs/docs/en/guide/feature/agent-email.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# AI Agent Mailbox Usage
|
||||
|
||||
For AI agents such as OpenClaw / Codex / Cursor: consume a temp mailbox directly using a user-supplied `Address JWT + API base URL` — list the inbox, fetch a single mail, extract verification codes / magic links.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The user must first open the frontend (e.g. `https://mail.example.com`) in a browser and **create or log into a mailbox address**. This step may require passing a Turnstile CAPTCHA that agents cannot complete automatically.
|
||||
|
||||
After creating or logging in, the **Address JWT** is displayed in the frontend UI and can be copied directly. The user provides the agent with:
|
||||
|
||||
1. **Address JWT** — copy from the frontend UI
|
||||
2. **API base URL** — same origin as the frontend, e.g. `https://mail.example.com`
|
||||
3. *(optional)* **Site password** — only if the deployment enabled `x-custom-auth`
|
||||
|
||||
### Credential persistence
|
||||
|
||||
To avoid entering credentials every time, the agent saves them to `~/.cf-temp-mail/credentials.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"base": "https://mail.example.com",
|
||||
"jwt": "<ADDRESS_JWT>",
|
||||
"site_password": ""
|
||||
}
|
||||
```
|
||||
|
||||
On first use, the agent reads the file if it exists, otherwise asks the user and saves for next time. Before each request it validates the JWT via `GET /api/settings` — if it returns `401`, the agent informs the user the JWT is expired, asks for a fresh one, and updates the file.
|
||||
|
||||
## Why `parsed_mail` API
|
||||
|
||||
By design, `/api/mails` and `/api/mail/:id` return raw RFC822 (`raw` field), so the agent must ship a MIME parser to obtain `subject` / `text` / `html`.
|
||||
|
||||
To let agents consume the mailbox directly, the project adds **server-parsed** read-only endpoints that reuse the same `postal-mime` logic used by the frontend:
|
||||
|
||||
| Task | Method | Path | Returns |
|
||||
| ----------------------- | ------ | ------------------------------------ | ----------------------------------------- |
|
||||
| Address info | GET | `/api/settings` | `{ address, send_balance }` |
|
||||
| List parsed mails | GET | `/api/parsed_mails?limit=&offset=` | `{ results: [parsedMail], count }` |
|
||||
| Get one parsed mail | GET | `/api/parsed_mail/:id` | `parsedMail` |
|
||||
|
||||
`limit` is clamped to `1..100`, `offset` is 0-based.
|
||||
|
||||
`parsedMail` shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"message_id": "<...>",
|
||||
"source": "noreply@foo.com",
|
||||
"to": "abc@yourdomain.com",
|
||||
"created_at": "2026-04-21 10:00:00",
|
||||
"sender": "Foo <noreply@foo.com>",
|
||||
"subject": "Your code is 123456",
|
||||
"text": "Your code is 123456\n",
|
||||
"html": "<p>Your code is <b>123456</b></p>",
|
||||
"attachments": [
|
||||
{ "filename": "a.pdf", "mimeType": "application/pdf", "disposition": "attachment", "size": 12345 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Attachment binary content is not included** in `parsed_*` responses — only metadata. If you need the bytes, fall back to `/api/mail/:id` and parse the raw source yourself.
|
||||
|
||||
## Required headers
|
||||
|
||||
- `Authorization: Bearer <JWT>` — required on every `/api/*` request
|
||||
- `x-custom-auth: <SITE_PASSWORD>` — only when the site enables the private password
|
||||
- `x-lang: en` or `zh` — optional, error-message language
|
||||
|
||||
::: warning Do not confuse Address JWT with User JWT
|
||||
Address JWT goes in `Authorization: Bearer`, User JWT goes in `x-user-token`. Mixing them returns `401 InvalidAddressCredentialMsg`.
|
||||
:::
|
||||
|
||||
## Examples
|
||||
|
||||
### 1. Smoke-test the JWT
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/settings" -H "Authorization: Bearer $JWT"
|
||||
# → { "address": "abc123@example.com", "send_balance": 0 }
|
||||
```
|
||||
|
||||
If this returns `401`, the JWT is wrong / expired / mismatched with `BASE` — ask the user for a fresh one.
|
||||
|
||||
### 2. List the inbox (parsed)
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/parsed_mails?limit=20&offset=0" \
|
||||
-H "Authorization: Bearer $JWT"
|
||||
```
|
||||
|
||||
### 3. Send mail
|
||||
|
||||
Requires `send_balance > 0` (check via `/api/settings`). The deployment must have a send method configured (Resend / SMTP / Cloudflare Email Routing binding).
|
||||
|
||||
| Task | Method | Path | Body / Returns |
|
||||
| ----------------------- | ------ | ------------------------------- | ------------------------------------------- |
|
||||
| Request send access | POST | `/api/request_send_mail_access` | `{}` → `{ status: "ok" }` |
|
||||
| Send mail | POST | `/api/send_mail` | `sendMailBody` → `{ status: "ok" }` |
|
||||
| List sent (sendbox) | GET | `/api/sendbox?limit=&offset=` | `{ results: [...], count }` |
|
||||
| Delete sent item | DELETE | `/api/sendbox/:id` | `{ success: true }` |
|
||||
|
||||
`sendMailBody`:
|
||||
|
||||
```json
|
||||
{
|
||||
"from_name": "My Name",
|
||||
"to_mail": "recipient@example.com",
|
||||
"to_name": "Recipient",
|
||||
"subject": "Hello",
|
||||
"content": "<p>Hi</p>",
|
||||
"is_html": true
|
||||
}
|
||||
```
|
||||
|
||||
`from_name` and `to_name` are optional (empty string is fine). `is_html: false` sends plain text.
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/send_mail" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"from_name":"","to_mail":"someone@example.com","to_name":"","subject":"Test","content":"Hello","is_html":false}'
|
||||
```
|
||||
|
||||
## Fallback: local parse of raw source
|
||||
|
||||
If `/api/parsed_mails` / `/api/parsed_mail/:id` returns `404` (older deployment) or a parse error, fall back to `/api/mails` / `/api/mail/:id` (RFC822 `raw`) and **parse locally with the same strategy as the frontend**: `mail-parser-wasm` first, `postal-mime` as fallback (implementation reference: `frontend/src/utils/email-parser.js`).
|
||||
|
||||
```bash
|
||||
npm i mail-parser-wasm postal-mime
|
||||
```
|
||||
|
||||
```js
|
||||
async function parseRaw(raw) {
|
||||
try {
|
||||
const { parse_message } = await import('mail-parser-wasm');
|
||||
const m = parse_message(raw);
|
||||
if (m?.subject && (m?.body_html || m?.text)) {
|
||||
return {
|
||||
sender: m.sender || '',
|
||||
subject: m.subject || '',
|
||||
text: m.text || '',
|
||||
html: m.body_html || '',
|
||||
attachments: (m.attachments || []).map(a => ({
|
||||
filename: a.filename || a.content_id || '',
|
||||
mimeType: a.content_type || '',
|
||||
size: a.content?.length ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
const PostalMime = (await import('postal-mime')).default;
|
||||
const p = await PostalMime.parse(raw);
|
||||
const sender = p.from?.name && p.from?.address
|
||||
? `${p.from.name} <${p.from.address}>`
|
||||
: (p.from?.address || '');
|
||||
return {
|
||||
sender,
|
||||
subject: p.subject || '',
|
||||
text: p.text || '',
|
||||
html: p.html || '',
|
||||
attachments: (p.attachments || []).map(a => ({
|
||||
filename: a.filename || a.contentId || '',
|
||||
mimeType: a.mimeType || '',
|
||||
size: a.content?.length ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const row = await (await fetch(`${BASE}/api/mail/${id}`, {
|
||||
headers: { Authorization: `Bearer ${JWT}` },
|
||||
})).json();
|
||||
const parsed = await parseRaw(row.raw);
|
||||
```
|
||||
|
||||
For attachment bytes, use `postal-mime` directly — `parsed.attachments[i].content` is a `Uint8Array`.
|
||||
|
||||
## Polling discipline
|
||||
|
||||
- Start at 3s, exponential backoff capped at 10s
|
||||
- Dedupe by mail `id`
|
||||
- Never poll faster than once per second
|
||||
- Respect `429` — sleep and retry
|
||||
|
||||
## `cf-temp-mail-agent-mail` Skill
|
||||
|
||||
The repo ships an agent skill at `.claude/skills/cf-temp-mail-agent-mail/` that wraps the flow above. Works with Claude Code / Cursor / Codex / OpenClaw and other agents.
|
||||
|
||||
Pick any install method:
|
||||
|
||||
```bash
|
||||
# Option 1: npx skills (recommended, auto-detects multiple agents)
|
||||
npx skills add dreamhunter2333/cloudflare_temp_email --skill cf-temp-mail-agent-mail
|
||||
# Add -g to install globally
|
||||
npx skills add dreamhunter2333/cloudflare_temp_email --skill cf-temp-mail-agent-mail -g
|
||||
|
||||
# Option 2: npx degit to copy into your agent's skills folder
|
||||
npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-agent-mail <your-agent-skills-dir>/cf-temp-mail-agent-mail
|
||||
|
||||
# Option 3: clone and copy
|
||||
git clone --depth 1 https://github.com/dreamhunter2333/cloudflare_temp_email.git /tmp/cf-temp-mail
|
||||
cp -r /tmp/cf-temp-mail/.claude/skills/cf-temp-mail-agent-mail <your-agent-skills-dir>/
|
||||
```
|
||||
|
||||
See [SKILL.md](https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/.claude/skills/cf-temp-mail-agent-mail/SKILL.md) for details.
|
||||
|
||||
## Common errors
|
||||
|
||||
- `401 InvalidAddressCredentialMsg` — JWT wrong / expired / sent via the wrong header. Ask the user for a fresh JWT.
|
||||
- `401 CustomAuthPasswordMsg` — site requires `x-custom-auth`; attach `SITE_PASSWORD`.
|
||||
- `400 InvalidLimitMsg` / `InvalidOffsetMsg` — `limit` must be 1..100, `offset ≥ 0`.
|
||||
- `429` — rate limited; back off and retry.
|
||||
212
vitepress-docs/docs/zh/guide/feature/agent-email.md
Normal file
212
vitepress-docs/docs/zh/guide/feature/agent-email.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# AI Agent 使用临时邮箱
|
||||
|
||||
面向 OpenClaw / Codex / Cursor 等 AI Agent,让它们用用户提供的 `Address JWT + API 地址`直接消费临时邮箱:列收件箱、取单封、提取验证码/魔法链接。
|
||||
|
||||
## 前提条件
|
||||
|
||||
用户需要先在浏览器中打开前端页面(如 `https://mail.example.com`),**创建或登录一个邮箱地址**。这一步可能需要通过 Turnstile 人机验证,Agent 无法自动完成。
|
||||
|
||||
创建/登录成功后,**Address JWT** 会显示在前端界面上,可直接复制。用户需要提供给 Agent:
|
||||
|
||||
1. **Address JWT** — 从前端界面复制
|
||||
2. **API 地址** — 与前端同源,如 `https://mail.example.com`
|
||||
3. *(可选)* **站点密码** — 仅当部署启用了 `x-custom-auth` 时需要
|
||||
|
||||
### 凭证持久化
|
||||
|
||||
为避免每次都要输入,Agent 会将凭证保存到 `~/.cf-temp-mail/credentials.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"base": "https://mail.example.com",
|
||||
"jwt": "<ADDRESS_JWT>",
|
||||
"site_password": ""
|
||||
}
|
||||
```
|
||||
|
||||
首次使用时如果文件存在则直接读取,不存在则向用户索要后保存。每次请求前通过 `GET /api/settings` 校验 JWT,若返回 `401` 则提示用户 JWT 已过期并更新文件。
|
||||
|
||||
## 为什么需要 `parsed_mail` API
|
||||
|
||||
`/api/mails` 与 `/api/mail/:id` 按设计返回原始 RFC822(`raw` 字段),Agent 侧需要自己解析 MIME 才能拿到 `subject`/`text`/`html`。
|
||||
|
||||
为方便 Agent 直接消费,项目新增了**服务端解析**的只读接口,复用前端同款的 `postal-mime` 解析逻辑:
|
||||
|
||||
| 任务 | 方法 | 路径 | 返回 |
|
||||
| ------------ | ---- | ------------------------------------ | ----------------------------------------- |
|
||||
| 地址信息 | GET | `/api/settings` | `{ address, send_balance }` |
|
||||
| 列出解析邮件 | GET | `/api/parsed_mails?limit=&offset=` | `{ results: [parsedMail], count }` |
|
||||
| 取单封解析 | GET | `/api/parsed_mail/:id` | `parsedMail` |
|
||||
|
||||
`limit` 范围 `1..100`,`offset` 从 0 开始。
|
||||
|
||||
`parsedMail` 结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"message_id": "<...>",
|
||||
"source": "noreply@foo.com",
|
||||
"to": "abc@yourdomain.com",
|
||||
"created_at": "2026-04-21 10:00:00",
|
||||
"sender": "Foo <noreply@foo.com>",
|
||||
"subject": "Your code is 123456",
|
||||
"text": "Your code is 123456\n",
|
||||
"html": "<p>Your code is <b>123456</b></p>",
|
||||
"attachments": [
|
||||
{ "filename": "a.pdf", "mimeType": "application/pdf", "disposition": "attachment", "size": 12345 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**附件二进制不包含**在 `parsed_*` 响应里,只有元数据。需要原始字节时再退回 `/api/mail/:id` 自己解析。
|
||||
|
||||
## 必要的请求头
|
||||
|
||||
- `Authorization: Bearer <JWT>` — 所有 `/api/*` 请求必须携带
|
||||
- `x-custom-auth: <SITE_PASSWORD>` — 仅当站点启用了私有密码
|
||||
- `x-lang: en` 或 `zh` — 可选,报错信息语言
|
||||
|
||||
::: warning 不要把 Address JWT 当 User JWT 用
|
||||
Address JWT 走 `Authorization: Bearer`,用户 JWT 走 `x-user-token`,两种凭证不可混用,否则返回 `401 InvalidAddressCredentialMsg`。
|
||||
:::
|
||||
|
||||
## 示例
|
||||
|
||||
### 1. 自检 JWT
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/settings" -H "Authorization: Bearer $JWT"
|
||||
# → { "address": "abc123@example.com", "send_balance": 0 }
|
||||
```
|
||||
|
||||
返回 `401` 说明 JWT 错/过期/和 `BASE` 不匹配,请用户重新提供。
|
||||
|
||||
### 2. 列表(解析后)
|
||||
|
||||
```bash
|
||||
curl -s "$BASE/api/parsed_mails?limit=20&offset=0" \
|
||||
-H "Authorization: Bearer $JWT"
|
||||
```
|
||||
|
||||
### 3. 发送邮件
|
||||
|
||||
需要 `send_balance > 0`(通过 `/api/settings` 查看),且部署方已配置发送方式(Resend / SMTP / Cloudflare Email Routing binding)。
|
||||
|
||||
| 任务 | 方法 | 路径 | 请求体 / 返回 |
|
||||
| ---------------- | ------ | ------------------------------- | ------------------------------------------ |
|
||||
| 申请发信权限 | POST | `/api/request_send_mail_access` | `{}` → `{ status: "ok" }` |
|
||||
| 发送邮件 | POST | `/api/send_mail` | `sendMailBody` → `{ status: "ok" }` |
|
||||
| 列出已发送 | GET | `/api/sendbox?limit=&offset=` | `{ results: [...], count }` |
|
||||
| 删除已发送 | DELETE | `/api/sendbox/:id` | `{ success: true }` |
|
||||
|
||||
`sendMailBody`:
|
||||
|
||||
```json
|
||||
{
|
||||
"from_name": "My Name",
|
||||
"to_mail": "recipient@example.com",
|
||||
"to_name": "Recipient",
|
||||
"subject": "Hello",
|
||||
"content": "<p>Hi</p>",
|
||||
"is_html": true
|
||||
}
|
||||
```
|
||||
|
||||
`from_name` 和 `to_name` 可选(空字符串即可)。`is_html: false` 发送纯文本。
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/send_mail" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"from_name":"","to_mail":"someone@example.com","to_name":"","subject":"Test","content":"Hello","is_html":false}'
|
||||
```
|
||||
|
||||
## 回退方案:本地解析 raw
|
||||
|
||||
若 `/api/parsed_mails` / `/api/parsed_mail/:id` 返回 `404`(较早部署未包含)或解析异常,回退到 `/api/mails` / `/api/mail/:id` 取 `raw`,**在本地按前端同款策略解析**:`mail-parser-wasm` 优先,失败时退回 `postal-mime`(实现参见 `frontend/src/utils/email-parser.js`)。
|
||||
|
||||
```bash
|
||||
npm i mail-parser-wasm postal-mime
|
||||
```
|
||||
|
||||
```js
|
||||
async function parseRaw(raw) {
|
||||
try {
|
||||
const { parse_message } = await import('mail-parser-wasm');
|
||||
const m = parse_message(raw);
|
||||
if (m?.subject && (m?.body_html || m?.text)) {
|
||||
return {
|
||||
sender: m.sender || '',
|
||||
subject: m.subject || '',
|
||||
text: m.text || '',
|
||||
html: m.body_html || '',
|
||||
attachments: (m.attachments || []).map(a => ({
|
||||
filename: a.filename || a.content_id || '',
|
||||
mimeType: a.content_type || '',
|
||||
size: a.content?.length ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
const PostalMime = (await import('postal-mime')).default;
|
||||
const p = await PostalMime.parse(raw);
|
||||
const sender = p.from?.name && p.from?.address
|
||||
? `${p.from.name} <${p.from.address}>`
|
||||
: (p.from?.address || '');
|
||||
return {
|
||||
sender,
|
||||
subject: p.subject || '',
|
||||
text: p.text || '',
|
||||
html: p.html || '',
|
||||
attachments: (p.attachments || []).map(a => ({
|
||||
filename: a.filename || a.contentId || '',
|
||||
mimeType: a.mimeType || '',
|
||||
size: a.content?.length ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const row = await (await fetch(`${BASE}/api/mail/${id}`, {
|
||||
headers: { Authorization: `Bearer ${JWT}` },
|
||||
})).json();
|
||||
const parsed = await parseRaw(row.raw);
|
||||
```
|
||||
|
||||
需要附件字节时直接用 `postal-mime`——`parsed.attachments[i].content` 是 `Uint8Array`。
|
||||
|
||||
## 轮询纪律
|
||||
|
||||
- 初始 3s 起步,指数退避,封顶 10s
|
||||
- 按 `id` 去重
|
||||
- 不要快于每秒 1 次
|
||||
- 遇到 `429` 必须 sleep 后重试
|
||||
|
||||
## `cf-temp-mail-agent-mail` Skill
|
||||
|
||||
仓库内置了 Agent 技能:`.claude/skills/cf-temp-mail-agent-mail/`,把上述流程封装成 AI Agent 可直接调用的形式,支持 Claude Code / Cursor / Codex / OpenClaw 等。
|
||||
|
||||
安装方式任选其一:
|
||||
|
||||
```bash
|
||||
# 方式 1:npx skills(推荐,自动适配多种 agent)
|
||||
npx skills add dreamhunter2333/cloudflare_temp_email --skill cf-temp-mail-agent-mail
|
||||
# 加 -g 安装到全局
|
||||
npx skills add dreamhunter2333/cloudflare_temp_email --skill cf-temp-mail-agent-mail -g
|
||||
|
||||
# 方式 2:npx degit 拷贝到你的 agent skills 目录
|
||||
npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-agent-mail <your-agent-skills-dir>/cf-temp-mail-agent-mail
|
||||
|
||||
# 方式 3:克隆后复制
|
||||
git clone --depth 1 https://github.com/dreamhunter2333/cloudflare_temp_email.git /tmp/cf-temp-mail
|
||||
cp -r /tmp/cf-temp-mail/.claude/skills/cf-temp-mail-agent-mail <your-agent-skills-dir>/
|
||||
```
|
||||
|
||||
详情见 [SKILL.md](https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/.claude/skills/cf-temp-mail-agent-mail/SKILL.md)。
|
||||
|
||||
## 常见错误
|
||||
|
||||
- `401 InvalidAddressCredentialMsg` — JWT 错/过期/header 填错,让用户重新提供
|
||||
- `401 CustomAuthPasswordMsg` — 站点启用了 `x-custom-auth`,附带 `SITE_PASSWORD`
|
||||
- `400 InvalidLimitMsg` / `InvalidOffsetMsg` — `limit` 必须 1..100,`offset ≥ 0`
|
||||
- `429` — 被限流,退避后重试
|
||||
133
worker/src/admin_api/account_settings_api.ts
Normal file
133
worker/src/admin_api/account_settings_api.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Context } from 'hono'
|
||||
|
||||
import i18n from '../i18n'
|
||||
import { getJsonSetting, saveSetting } from '../utils'
|
||||
import { getAddressCreationSettings, getAddressCreationSubdomainMatchStatus } from '../common'
|
||||
import { CONSTANTS } from '../constants'
|
||||
import {
|
||||
getSendMailLimitConfig,
|
||||
getSendMailLimitConfigToSave,
|
||||
validateSendMailLimitConfig
|
||||
} from '../mails_api/send_mail_limit_utils'
|
||||
import { EmailRuleSettings } from '../models'
|
||||
|
||||
const normalizeAddressCreationSettingsUpdate = (
|
||||
value: unknown
|
||||
): {
|
||||
shouldUpdate: boolean,
|
||||
shouldClear: boolean,
|
||||
nextEnableSubdomainMatch?: boolean,
|
||||
} | null => {
|
||||
if (typeof value === 'undefined') {
|
||||
return { shouldUpdate: false, shouldClear: false };
|
||||
}
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const nextEnableSubdomainMatch = (value as Record<string, unknown>).enableSubdomainMatch;
|
||||
if (typeof nextEnableSubdomainMatch === 'undefined') {
|
||||
return { shouldUpdate: false, shouldClear: false };
|
||||
}
|
||||
// null 代表"清空后台覆盖,恢复为未设置并回退到 env",这是给前端三态显式使用的正式路径。
|
||||
if (nextEnableSubdomainMatch === null) {
|
||||
return { shouldUpdate: true, shouldClear: true };
|
||||
}
|
||||
if (typeof nextEnableSubdomainMatch !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
shouldUpdate: true,
|
||||
shouldClear: false,
|
||||
nextEnableSubdomainMatch,
|
||||
};
|
||||
};
|
||||
|
||||
const get = async (c: Context<HonoCustomType>) => {
|
||||
try {
|
||||
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
|
||||
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
|
||||
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY);
|
||||
const fromBlockList = c.env.KV ? await c.env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : [];
|
||||
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY);
|
||||
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
|
||||
const addressCreationSettings = await getAddressCreationSettings(c);
|
||||
const addressCreationSubdomainMatchStatus = await getAddressCreationSubdomainMatchStatus(c, addressCreationSettings);
|
||||
const sendMailLimitConfig = await getSendMailLimitConfig(c);
|
||||
return c.json({
|
||||
blockList: blockList || [],
|
||||
sendBlockList: sendBlockList || [],
|
||||
verifiedAddressList: verifiedAddressList || [],
|
||||
fromBlockList: fromBlockList || [],
|
||||
noLimitSendAddressList: noLimitSendAddressList || [],
|
||||
emailRuleSettings: emailRuleSettings || {},
|
||||
addressCreationSettings: typeof addressCreationSettings.enableSubdomainMatch === 'boolean'
|
||||
? { enableSubdomainMatch: addressCreationSettings.enableSubdomainMatch }
|
||||
: {},
|
||||
addressCreationSubdomainMatchStatus,
|
||||
sendMailLimitConfig,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return c.json({})
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const {
|
||||
blockList, sendBlockList, noLimitSendAddressList,
|
||||
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings,
|
||||
sendMailLimitConfig
|
||||
} = await c.req.json();
|
||||
if (!blockList || !sendBlockList || !verifiedAddressList) {
|
||||
return c.text(msgs.InvalidInputMsg, 400)
|
||||
}
|
||||
const addressCreationSettingsUpdate = normalizeAddressCreationSettingsUpdate(addressCreationSettings);
|
||||
if (!addressCreationSettingsUpdate) {
|
||||
return c.text(msgs.InvalidInputMsg, 400)
|
||||
}
|
||||
if (!c.env.SEND_MAIL && verifiedAddressList.length > 0) {
|
||||
return c.text(msgs.EnableSendMailMsg, 400)
|
||||
}
|
||||
// 所有输入依赖都先校验,再执行任意写入,避免接口返回 400 时出现部分设置已落库的半成功状态。
|
||||
if (fromBlockList?.length > 0 && !c.env.KV) {
|
||||
return c.text(msgs.EnableKVMsg, 400)
|
||||
}
|
||||
if (sendMailLimitConfig && !validateSendMailLimitConfig(sendMailLimitConfig)) {
|
||||
return c.text(msgs.InvalidInputMsg, 400)
|
||||
}
|
||||
const sendMailLimitConfigToSave = sendMailLimitConfig
|
||||
? getSendMailLimitConfigToSave(sendMailLimitConfig)
|
||||
: null;
|
||||
await saveSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY, JSON.stringify(blockList));
|
||||
await saveSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY, JSON.stringify(sendBlockList));
|
||||
await saveSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY, JSON.stringify(verifiedAddressList));
|
||||
if (fromBlockList?.length > 0 && c.env.KV) {
|
||||
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList))
|
||||
}
|
||||
await saveSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY, JSON.stringify(noLimitSendAddressList || []));
|
||||
await saveSetting(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY, JSON.stringify(emailRuleSettings || {}));
|
||||
if (addressCreationSettingsUpdate.shouldUpdate) {
|
||||
if (addressCreationSettingsUpdate.shouldClear) {
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM settings WHERE key = ?`
|
||||
).bind(CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY).run();
|
||||
} else {
|
||||
await saveSetting(
|
||||
c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY,
|
||||
JSON.stringify({
|
||||
enableSubdomainMatch: addressCreationSettingsUpdate.nextEnableSubdomainMatch
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
if (sendMailLimitConfigToSave) {
|
||||
await saveSetting(
|
||||
c, CONSTANTS.SEND_MAIL_LIMIT_CONFIG_KEY,
|
||||
JSON.stringify(sendMailLimitConfigToSave)
|
||||
)
|
||||
}
|
||||
return c.json({ success: true });
|
||||
};
|
||||
|
||||
export default { get, save };
|
||||
159
worker/src/admin_api/address_api.ts
Normal file
159
worker/src/admin_api/address_api.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Context } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import i18n from '../i18n'
|
||||
import { getBooleanValue, hashPassword } from '../utils'
|
||||
import { newAddress, handleListQuery } from '../common'
|
||||
|
||||
const listAddresses = async (c: Context<HonoCustomType>) => {
|
||||
const { limit, offset, query, sort_by, sort_order } = c.req.query();
|
||||
const allowedSortColumns: Record<string, string> = {
|
||||
'id': 'a.id',
|
||||
'name': 'a.name',
|
||||
'created_at': 'a.created_at',
|
||||
'updated_at': 'a.updated_at',
|
||||
'source_meta': 'a.source_meta',
|
||||
'mail_count': 'mail_count',
|
||||
'send_count': 'send_count',
|
||||
};
|
||||
const sortColumn = Object.hasOwn(allowedSortColumns, sort_by) ? allowedSortColumns[sort_by] : 'a.id';
|
||||
const sortDirection = sort_order === 'ascend' ? 'asc' : 'desc';
|
||||
const orderBy = `${sortColumn} ${sortDirection}`;
|
||||
if (query) {
|
||||
// D1 caps LIKE pattern length at 50 bytes; fall back to instr() for
|
||||
// longer queries to avoid "LIKE or GLOB pattern too complex" (#956).
|
||||
const useInstr = new TextEncoder().encode(query).length + 2 > 50;
|
||||
const whereClause = useInstr ? `instr(name, ?) > 0` : `name like ?`;
|
||||
const param = useInstr ? query : `%${query}%`;
|
||||
return await handleListQuery(c,
|
||||
`SELECT a.*,`
|
||||
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
|
||||
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
||||
+ ` FROM address a`
|
||||
+ ` where ${whereClause}`,
|
||||
`SELECT count(*) as count FROM address where ${whereClause}`,
|
||||
[param], limit, offset, orderBy
|
||||
);
|
||||
}
|
||||
return await handleListQuery(c,
|
||||
`SELECT a.*,`
|
||||
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
|
||||
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
||||
+ ` FROM address a`,
|
||||
`SELECT count(*) as count FROM address`,
|
||||
[], limit, offset, orderBy
|
||||
);
|
||||
};
|
||||
|
||||
const createNewAddress = async (c: Context<HonoCustomType>) => {
|
||||
const { name, domain, enablePrefix, enableRandomSubdomain } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!name) {
|
||||
return c.text(msgs.RequiredFieldMsg, 400)
|
||||
}
|
||||
try {
|
||||
const res = await newAddress(c, {
|
||||
name, domain, enablePrefix,
|
||||
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
|
||||
checkLengthByConfig: false,
|
||||
addressPrefix: null,
|
||||
checkAllowDomains: false,
|
||||
enableCheckNameRegex: false,
|
||||
sourceMeta: 'admin'
|
||||
});
|
||||
return c.json(res);
|
||||
} catch (e) {
|
||||
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAddress = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM address WHERE id = ? `
|
||||
).bind(id).run();
|
||||
if (!success) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
if (!mailSuccess) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
const { success: sendAccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
const { success: usersAddressSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM users_address WHERE address_id = ?`
|
||||
).bind(id).run();
|
||||
return c.json({
|
||||
success: success && mailSuccess && sendAccess && usersAddressSuccess
|
||||
})
|
||||
};
|
||||
|
||||
const clearInbox = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
if (!mailSuccess) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
return c.json({ success: mailSuccess });
|
||||
};
|
||||
|
||||
const clearSentItems = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { success: sendboxSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM sendbox WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
if (!sendboxSuccess) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
return c.json({ success: sendboxSuccess });
|
||||
};
|
||||
|
||||
const showPassword = async (c: Context<HonoCustomType>) => {
|
||||
const { id } = c.req.param();
|
||||
const name = await c.env.DB.prepare(
|
||||
`SELECT name FROM address WHERE id = ? `
|
||||
).bind(id).first("name");
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: id
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
return c.json({ jwt });
|
||||
};
|
||||
|
||||
const resetPassword = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { password } = await c.req.json();
|
||||
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
|
||||
return c.text(msgs.PasswordChangeDisabledMsg, 403);
|
||||
}
|
||||
if (!password) {
|
||||
return c.text(msgs.NewPasswordRequiredMsg, 400);
|
||||
}
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
).bind(hashedPassword, id).run();
|
||||
if (!success) {
|
||||
return c.text(msgs.FailedUpdatePasswordMsg, 500);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
};
|
||||
|
||||
export default {
|
||||
listAddresses, createNewAddress, deleteAddress, clearInbox, clearSentItems,
|
||||
showPassword, resetPassword
|
||||
};
|
||||
53
worker/src/admin_api/address_sender_api.ts
Normal file
53
worker/src/admin_api/address_sender_api.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Context } from 'hono'
|
||||
|
||||
import i18n from '../i18n'
|
||||
import { sendAdminInternalMail } from '../utils'
|
||||
import { handleListQuery } from '../common'
|
||||
|
||||
const list = async (c: Context<HonoCustomType>) => {
|
||||
const { address, limit, offset } = c.req.query();
|
||||
if (address) {
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM address_sender where address = ? `,
|
||||
`SELECT count(*) as count FROM address_sender where address = ? `,
|
||||
[address], limit, offset
|
||||
);
|
||||
}
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM address_sender `,
|
||||
`SELECT count(*) as count FROM address_sender `,
|
||||
[], limit, offset
|
||||
);
|
||||
};
|
||||
|
||||
const update = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
/* eslint-disable prefer-const */
|
||||
let { address, address_id, balance, enabled } = await c.req.json();
|
||||
/* eslint-enable prefer-const */
|
||||
if (!address_id) {
|
||||
return c.text(msgs.InvalidAddressIdMsg, 400)
|
||||
}
|
||||
enabled = enabled ? 1 : 0;
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE address_sender SET enabled = ?, balance = ? WHERE id = ? `
|
||||
).bind(enabled, balance, address_id).run();
|
||||
if (!success) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
await sendAdminInternalMail(
|
||||
c, address, "Account Send Access Updated",
|
||||
`Your send access has been ${enabled ? "enabled" : "disabled"}, balance: ${balance}`
|
||||
);
|
||||
return c.json({ success });
|
||||
};
|
||||
|
||||
const remove = async (c: Context<HonoCustomType>) => {
|
||||
const { id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE id = ? `
|
||||
).bind(id).run();
|
||||
return c.json({ success });
|
||||
};
|
||||
|
||||
export default { list, update, remove };
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Hono } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { Context, Hono } from 'hono'
|
||||
|
||||
import i18n from '../i18n'
|
||||
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles, getBooleanValue, hashPassword } from '../utils'
|
||||
import { newAddress, handleListQuery, getAddressCreationSettings, getAddressCreationSubdomainMatchStatus } from '../common'
|
||||
import { CONSTANTS } from '../constants'
|
||||
import { getUserRoles } from '../utils'
|
||||
import address_api from './address_api'
|
||||
import address_sender_api from './address_sender_api'
|
||||
import sendbox_api from './sendbox_api'
|
||||
import statistics_api from './statistics_api'
|
||||
import account_settings_api from './account_settings_api'
|
||||
import cleanup_api from './cleanup_api'
|
||||
import admin_user_api from './admin_user_api'
|
||||
import webhook_settings from './webhook_settings'
|
||||
@@ -13,434 +14,42 @@ import oauth2_settings from './oauth2_settings'
|
||||
import worker_config from './worker_config'
|
||||
import admin_mail_api from './admin_mail_api'
|
||||
import { sendMailbyAdmin, sendMailByBindingAdmin } from './send_mail'
|
||||
import {
|
||||
getSendMailLimitConfig,
|
||||
getSendMailLimitConfigToSave,
|
||||
validateSendMailLimitConfig
|
||||
} from '../mails_api/send_mail_limit_utils'
|
||||
import db_api from './db_api'
|
||||
import ip_blacklist_settings from './ip_blacklist_settings'
|
||||
import ai_extract_settings from './ai_extract_settings'
|
||||
import { EmailRuleSettings } from '../models'
|
||||
import e2e_test_api from './e2e_test_api'
|
||||
|
||||
export const api = new Hono<HonoCustomType>()
|
||||
|
||||
const normalizeAddressCreationSettingsUpdate = (
|
||||
value: unknown
|
||||
): {
|
||||
shouldUpdate: boolean,
|
||||
shouldClear: boolean,
|
||||
nextEnableSubdomainMatch?: boolean,
|
||||
} | null => {
|
||||
if (typeof value === 'undefined') {
|
||||
return {
|
||||
shouldUpdate: false,
|
||||
shouldClear: false,
|
||||
};
|
||||
}
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const nextEnableSubdomainMatch = (value as Record<string, unknown>).enableSubdomainMatch;
|
||||
if (typeof nextEnableSubdomainMatch === 'undefined') {
|
||||
return {
|
||||
shouldUpdate: false,
|
||||
shouldClear: false,
|
||||
};
|
||||
}
|
||||
// null 代表“清空后台覆盖,恢复为未设置并回退到 env”,这是给前端三态显式使用的正式路径。
|
||||
if (nextEnableSubdomainMatch === null) {
|
||||
return {
|
||||
shouldUpdate: true,
|
||||
shouldClear: true,
|
||||
};
|
||||
}
|
||||
if (typeof nextEnableSubdomainMatch !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
shouldUpdate: true,
|
||||
shouldClear: false,
|
||||
nextEnableSubdomainMatch,
|
||||
};
|
||||
}
|
||||
|
||||
api.get('/admin/address', async (c) => {
|
||||
const { limit, offset, query, sort_by, sort_order } = c.req.query();
|
||||
const allowedSortColumns: Record<string, string> = {
|
||||
'id': 'a.id',
|
||||
'name': 'a.name',
|
||||
'created_at': 'a.created_at',
|
||||
'updated_at': 'a.updated_at',
|
||||
'source_meta': 'a.source_meta',
|
||||
'mail_count': 'mail_count',
|
||||
'send_count': 'send_count',
|
||||
};
|
||||
const sortColumn = Object.hasOwn(allowedSortColumns, sort_by) ? allowedSortColumns[sort_by] : 'a.id';
|
||||
const sortDirection = sort_order === 'ascend' ? 'asc' : 'desc';
|
||||
const orderBy = `${sortColumn} ${sortDirection}`;
|
||||
if (query) {
|
||||
// D1 caps LIKE pattern length at 50 bytes; fall back to instr() for
|
||||
// longer queries to avoid "LIKE or GLOB pattern too complex" (#956).
|
||||
const useInstr = new TextEncoder().encode(query).length + 2 > 50;
|
||||
const whereClause = useInstr ? `instr(name, ?) > 0` : `name like ?`;
|
||||
const param = useInstr ? query : `%${query}%`;
|
||||
return await handleListQuery(c,
|
||||
`SELECT a.*,`
|
||||
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
|
||||
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
||||
+ ` FROM address a`
|
||||
+ ` where ${whereClause}`,
|
||||
`SELECT count(*) as count FROM address where ${whereClause}`,
|
||||
[param], limit, offset, orderBy
|
||||
);
|
||||
}
|
||||
return await handleListQuery(c,
|
||||
`SELECT a.*,`
|
||||
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
|
||||
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
||||
+ ` FROM address a`,
|
||||
`SELECT count(*) as count FROM address`,
|
||||
[], limit, offset, orderBy
|
||||
);
|
||||
})
|
||||
|
||||
api.post('/admin/new_address', async (c) => {
|
||||
const { name, domain, enablePrefix, enableRandomSubdomain } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!name) {
|
||||
return c.text(msgs.RequiredFieldMsg, 400)
|
||||
}
|
||||
try {
|
||||
const res = await newAddress(c, {
|
||||
name, domain, enablePrefix,
|
||||
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
|
||||
checkLengthByConfig: false,
|
||||
addressPrefix: null,
|
||||
checkAllowDomains: false,
|
||||
enableCheckNameRegex: false,
|
||||
sourceMeta: 'admin'
|
||||
});
|
||||
|
||||
return c.json(res);
|
||||
} catch (e) {
|
||||
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
|
||||
}
|
||||
})
|
||||
|
||||
api.delete('/admin/delete_address/:id', async (c) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM address WHERE id = ? `
|
||||
).bind(id).run();
|
||||
if (!success) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
if (!mailSuccess) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
const { success: sendAccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
const { success: usersAddressSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM users_address WHERE address_id = ?`
|
||||
).bind(id).run();
|
||||
return c.json({
|
||||
success: success && mailSuccess && sendAccess && usersAddressSuccess
|
||||
})
|
||||
})
|
||||
|
||||
api.delete('/admin/clear_inbox/:id', async (c) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
if (!mailSuccess) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
return c.json({
|
||||
success: mailSuccess
|
||||
})
|
||||
})
|
||||
|
||||
api.delete('/admin/clear_sent_items/:id', async (c) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { success: sendboxSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM sendbox WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
if (!sendboxSuccess) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
return c.json({
|
||||
success: sendboxSuccess
|
||||
})
|
||||
})
|
||||
|
||||
api.get('/admin/show_password/:id', async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const name = await c.env.DB.prepare(
|
||||
`SELECT name FROM address WHERE id = ? `
|
||||
).bind(id).first("name");
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: id
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
return c.json({
|
||||
jwt: jwt
|
||||
})
|
||||
})
|
||||
|
||||
api.post('/admin/address/:id/reset_password', async (c) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { password } = await c.req.json();
|
||||
// 检查功能是否启用
|
||||
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
|
||||
return c.text(msgs.PasswordChangeDisabledMsg, 403);
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return c.text(msgs.NewPasswordRequiredMsg, 400);
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
).bind(hashedPassword, id).run();
|
||||
|
||||
if (!success) {
|
||||
return c.text(msgs.FailedUpdatePasswordMsg, 500);
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
})
|
||||
// address
|
||||
api.get('/admin/address', address_api.listAddresses)
|
||||
api.post('/admin/new_address', address_api.createNewAddress)
|
||||
api.delete('/admin/delete_address/:id', address_api.deleteAddress)
|
||||
api.delete('/admin/clear_inbox/:id', address_api.clearInbox)
|
||||
api.delete('/admin/clear_sent_items/:id', address_api.clearSentItems)
|
||||
api.get('/admin/show_password/:id', address_api.showPassword)
|
||||
api.post('/admin/address/:id/reset_password', address_api.resetPassword)
|
||||
|
||||
// mail api
|
||||
api.get('/admin/mails', admin_mail_api.getMails);
|
||||
api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails);
|
||||
api.get('/admin/mails', admin_mail_api.getMails)
|
||||
api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails)
|
||||
api.delete('/admin/mails/:id', admin_mail_api.deleteMail)
|
||||
|
||||
api.get('/admin/address_sender', async (c) => {
|
||||
const { address, limit, offset } = c.req.query();
|
||||
if (address) {
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM address_sender where address = ? `,
|
||||
`SELECT count(*) as count FROM address_sender where address = ? `,
|
||||
[address], limit, offset
|
||||
);
|
||||
}
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM address_sender `,
|
||||
`SELECT count(*) as count FROM address_sender `,
|
||||
[], limit, offset
|
||||
);
|
||||
})
|
||||
// address sender
|
||||
api.get('/admin/address_sender', address_sender_api.list)
|
||||
api.post('/admin/address_sender', address_sender_api.update)
|
||||
api.delete('/admin/address_sender/:id', address_sender_api.remove)
|
||||
|
||||
api.post('/admin/address_sender', async (c) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
/* eslint-disable prefer-const */
|
||||
let { address, address_id, balance, enabled } = await c.req.json();
|
||||
/* eslint-enable prefer-const */
|
||||
if (!address_id) {
|
||||
return c.text(msgs.InvalidAddressIdMsg, 400)
|
||||
}
|
||||
enabled = enabled ? 1 : 0;
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE address_sender SET enabled = ?, balance = ? WHERE id = ? `
|
||||
).bind(enabled, balance, address_id).run();
|
||||
if (!success) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
await sendAdminInternalMail(
|
||||
c, address, "Account Send Access Updated",
|
||||
`Your send access has been ${enabled ? "enabled" : "disabled"}, balance: ${balance}`
|
||||
);
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
})
|
||||
// sendbox
|
||||
api.get('/admin/sendbox', sendbox_api.list)
|
||||
api.delete('/admin/sendbox/:id', sendbox_api.remove)
|
||||
|
||||
api.delete('/admin/address_sender/:id', async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE id = ? `
|
||||
).bind(id).run();
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
})
|
||||
// statistics
|
||||
api.get('/admin/statistics', statistics_api.get)
|
||||
|
||||
api.get('/admin/sendbox', async (c) => {
|
||||
const { address, limit, offset } = c.req.query();
|
||||
if (address) {
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM sendbox where address = ? `,
|
||||
`SELECT count(*) as count FROM sendbox where address = ? `,
|
||||
[address], limit, offset
|
||||
);
|
||||
}
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM sendbox `,
|
||||
`SELECT count(*) as count FROM sendbox `,
|
||||
[], limit, offset
|
||||
);
|
||||
})
|
||||
|
||||
api.delete('/admin/sendbox/:id', async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM sendbox WHERE id = ? `
|
||||
).bind(id).run();
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
})
|
||||
|
||||
api.get('/admin/statistics', async (c) => {
|
||||
const { count: mailCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM raw_mails`
|
||||
).first<{ count: number }>() || {};
|
||||
const { count: addressCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM address`
|
||||
).first<{ count: number }>() || {};
|
||||
const { count: activeAddressCount7days } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
|
||||
).first<{ count: number }>() || {};
|
||||
const { count: activeAddressCount30days } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-30 day')`
|
||||
).first<{ count: number }>() || {};
|
||||
const { count: sendMailCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM sendbox`
|
||||
).first<{ count: number }>() || {};
|
||||
const { count: userCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM users`
|
||||
).first<{ count: number }>() || {};
|
||||
return c.json({
|
||||
mailCount: mailCount,
|
||||
addressCount: addressCount,
|
||||
activeAddressCount7days: activeAddressCount7days,
|
||||
activeAddressCount30days: activeAddressCount30days,
|
||||
userCount: userCount,
|
||||
sendMailCount: sendMailCount
|
||||
})
|
||||
});
|
||||
|
||||
api.get('/admin/account_settings', async (c) => {
|
||||
try {
|
||||
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
|
||||
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
|
||||
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY);
|
||||
const fromBlockList = c.env.KV ? await c.env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : [];
|
||||
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY);
|
||||
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
|
||||
const addressCreationSettings = await getAddressCreationSettings(c);
|
||||
const addressCreationSubdomainMatchStatus = await getAddressCreationSubdomainMatchStatus(c, addressCreationSettings);
|
||||
const sendMailLimitConfig = await getSendMailLimitConfig(c);
|
||||
return c.json({
|
||||
blockList: blockList || [],
|
||||
sendBlockList: sendBlockList || [],
|
||||
verifiedAddressList: verifiedAddressList || [],
|
||||
fromBlockList: fromBlockList || [],
|
||||
noLimitSendAddressList: noLimitSendAddressList || [],
|
||||
emailRuleSettings: emailRuleSettings || {},
|
||||
addressCreationSettings: typeof addressCreationSettings.enableSubdomainMatch === 'boolean'
|
||||
? { enableSubdomainMatch: addressCreationSettings.enableSubdomainMatch }
|
||||
: {},
|
||||
addressCreationSubdomainMatchStatus,
|
||||
sendMailLimitConfig,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return c.json({})
|
||||
}
|
||||
})
|
||||
|
||||
api.post('/admin/account_settings', async (c) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
|
||||
const {
|
||||
blockList, sendBlockList, noLimitSendAddressList,
|
||||
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings,
|
||||
sendMailLimitConfig
|
||||
} = await c.req.json();
|
||||
if (!blockList || !sendBlockList || !verifiedAddressList) {
|
||||
return c.text(msgs.InvalidInputMsg, 400)
|
||||
}
|
||||
const addressCreationSettingsUpdate = normalizeAddressCreationSettingsUpdate(addressCreationSettings);
|
||||
if (!addressCreationSettingsUpdate) {
|
||||
return c.text(msgs.InvalidInputMsg, 400)
|
||||
}
|
||||
if (!c.env.SEND_MAIL && verifiedAddressList.length > 0) {
|
||||
return c.text(msgs.EnableSendMailMsg, 400)
|
||||
}
|
||||
// 所有输入依赖都先校验,再执行任意写入,避免接口返回 400 时出现部分设置已落库的半成功状态。
|
||||
if (fromBlockList?.length > 0 && !c.env.KV) {
|
||||
return c.text(msgs.EnableKVMsg, 400)
|
||||
}
|
||||
if (sendMailLimitConfig && !validateSendMailLimitConfig(sendMailLimitConfig)) {
|
||||
return c.text(msgs.InvalidInputMsg, 400)
|
||||
}
|
||||
const sendMailLimitConfigToSave = sendMailLimitConfig
|
||||
? getSendMailLimitConfigToSave(sendMailLimitConfig)
|
||||
: null;
|
||||
await saveSetting(
|
||||
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
|
||||
JSON.stringify(blockList)
|
||||
);
|
||||
await saveSetting(
|
||||
c, CONSTANTS.SEND_BLOCK_LIST_KEY,
|
||||
JSON.stringify(sendBlockList)
|
||||
);
|
||||
await saveSetting(
|
||||
c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY,
|
||||
JSON.stringify(verifiedAddressList)
|
||||
)
|
||||
if (fromBlockList?.length > 0 && c.env.KV) {
|
||||
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList))
|
||||
}
|
||||
await saveSetting(
|
||||
c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY,
|
||||
JSON.stringify(noLimitSendAddressList || [])
|
||||
)
|
||||
await saveSetting(
|
||||
c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY,
|
||||
JSON.stringify(emailRuleSettings || {})
|
||||
)
|
||||
if (addressCreationSettingsUpdate.shouldUpdate) {
|
||||
if (addressCreationSettingsUpdate.shouldClear) {
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM settings WHERE key = ?`
|
||||
).bind(CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY).run();
|
||||
} else {
|
||||
await saveSetting(
|
||||
c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY,
|
||||
JSON.stringify({
|
||||
enableSubdomainMatch: addressCreationSettingsUpdate.nextEnableSubdomainMatch
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
if (sendMailLimitConfigToSave) {
|
||||
await saveSetting(
|
||||
c, CONSTANTS.SEND_MAIL_LIMIT_CONFIG_KEY,
|
||||
JSON.stringify(sendMailLimitConfigToSave)
|
||||
)
|
||||
}
|
||||
return c.json({
|
||||
success: true
|
||||
})
|
||||
})
|
||||
// account settings
|
||||
api.get('/admin/account_settings', account_settings_api.get)
|
||||
api.post('/admin/account_settings', account_settings_api.save)
|
||||
|
||||
// cleanup
|
||||
api.post('/admin/cleanup', cleanup_api.cleanup)
|
||||
@@ -454,7 +63,7 @@ api.get('/admin/users', admin_user_api.getUsers)
|
||||
api.delete('/admin/users/:user_id', admin_user_api.deleteUser)
|
||||
api.post('/admin/users', admin_user_api.createUser)
|
||||
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
|
||||
api.get('/admin/user_roles', async (c) => c.json(getUserRoles(c)))
|
||||
api.get('/admin/user_roles', async (c: Context<HonoCustomType>) => c.json(getUserRoles(c)))
|
||||
api.post('/admin/user_roles', admin_user_api.updateUserRoles)
|
||||
api.get('/admin/role_address_config', admin_user_api.getRoleAddressConfig)
|
||||
api.post('/admin/role_address_config', admin_user_api.saveRoleAddressConfig)
|
||||
@@ -466,34 +75,34 @@ api.get('/admin/user_oauth2_settings', oauth2_settings.getUserOauth2Settings)
|
||||
api.post('/admin/user_oauth2_settings', oauth2_settings.saveUserOauth2Settings)
|
||||
|
||||
// webhook settings
|
||||
api.get("/admin/webhook/settings", webhook_settings.getWebhookSettings);
|
||||
api.post("/admin/webhook/settings", webhook_settings.saveWebhookSettings);
|
||||
api.get('/admin/webhook/settings', webhook_settings.getWebhookSettings)
|
||||
api.post('/admin/webhook/settings', webhook_settings.saveWebhookSettings)
|
||||
|
||||
// mail webhook settings
|
||||
api.get("/admin/mail_webhook/settings", mail_webhook_settings.getWebhookSettings);
|
||||
api.post("/admin/mail_webhook/settings", mail_webhook_settings.saveWebhookSettings);
|
||||
api.post("/admin/mail_webhook/test", mail_webhook_settings.testWebhookSettings);
|
||||
api.get('/admin/mail_webhook/settings', mail_webhook_settings.getWebhookSettings)
|
||||
api.post('/admin/mail_webhook/settings', mail_webhook_settings.saveWebhookSettings)
|
||||
api.post('/admin/mail_webhook/test', mail_webhook_settings.testWebhookSettings)
|
||||
|
||||
// worker config
|
||||
api.get("/admin/worker/configs", worker_config.getConfig);
|
||||
api.get('/admin/worker/configs', worker_config.getConfig)
|
||||
|
||||
// send mail by admin
|
||||
api.post("/admin/send_mail", sendMailbyAdmin);
|
||||
api.post("/admin/send_mail_by_binding", sendMailByBindingAdmin);
|
||||
api.post('/admin/send_mail', sendMailbyAdmin)
|
||||
api.post('/admin/send_mail_by_binding', sendMailByBindingAdmin)
|
||||
|
||||
// db api
|
||||
api.get('admin/db_version', db_api.getVersion);
|
||||
api.post('admin/db_initialize', db_api.initialize);
|
||||
api.post('admin/db_migration', db_api.migrate);
|
||||
api.get('admin/db_version', db_api.getVersion)
|
||||
api.post('admin/db_initialize', db_api.initialize)
|
||||
api.post('admin/db_migration', db_api.migrate)
|
||||
|
||||
// IP blacklist settings
|
||||
api.get("/admin/ip_blacklist/settings", ip_blacklist_settings.getIpBlacklistSettings);
|
||||
api.post("/admin/ip_blacklist/settings", ip_blacklist_settings.saveIpBlacklistSettings);
|
||||
api.get('/admin/ip_blacklist/settings', ip_blacklist_settings.getIpBlacklistSettings)
|
||||
api.post('/admin/ip_blacklist/settings', ip_blacklist_settings.saveIpBlacklistSettings)
|
||||
|
||||
// AI extract settings
|
||||
api.get("/admin/ai_extract/settings", ai_extract_settings.getAiExtractSettings);
|
||||
api.post("/admin/ai_extract/settings", ai_extract_settings.saveAiExtractSettings);
|
||||
api.get('/admin/ai_extract/settings', ai_extract_settings.getAiExtractSettings)
|
||||
api.post('/admin/ai_extract/settings', ai_extract_settings.saveAiExtractSettings)
|
||||
|
||||
// E2E test endpoints
|
||||
api.post('/admin/test/seed_mail', e2e_test_api.seedMail);
|
||||
api.post('/admin/test/receive_mail', e2e_test_api.receiveMail);
|
||||
api.post('/admin/test/seed_mail', e2e_test_api.seedMail)
|
||||
api.post('/admin/test/receive_mail', e2e_test_api.receiveMail)
|
||||
|
||||
29
worker/src/admin_api/sendbox_api.ts
Normal file
29
worker/src/admin_api/sendbox_api.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Context } from 'hono'
|
||||
|
||||
import { handleListQuery } from '../common'
|
||||
|
||||
const list = async (c: Context<HonoCustomType>) => {
|
||||
const { address, limit, offset } = c.req.query();
|
||||
if (address) {
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM sendbox where address = ? `,
|
||||
`SELECT count(*) as count FROM sendbox where address = ? `,
|
||||
[address], limit, offset
|
||||
);
|
||||
}
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM sendbox `,
|
||||
`SELECT count(*) as count FROM sendbox `,
|
||||
[], limit, offset
|
||||
);
|
||||
};
|
||||
|
||||
const remove = async (c: Context<HonoCustomType>) => {
|
||||
const { id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM sendbox WHERE id = ? `
|
||||
).bind(id).run();
|
||||
return c.json({ success });
|
||||
};
|
||||
|
||||
export default { list, remove };
|
||||
32
worker/src/admin_api/statistics_api.ts
Normal file
32
worker/src/admin_api/statistics_api.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Context } from 'hono'
|
||||
|
||||
const get = async (c: Context<HonoCustomType>) => {
|
||||
const { count: mailCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM raw_mails`
|
||||
).first<{ count: number }>() || {};
|
||||
const { count: addressCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM address`
|
||||
).first<{ count: number }>() || {};
|
||||
const { count: activeAddressCount7days } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
|
||||
).first<{ count: number }>() || {};
|
||||
const { count: activeAddressCount30days } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-30 day')`
|
||||
).first<{ count: number }>() || {};
|
||||
const { count: sendMailCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM sendbox`
|
||||
).first<{ count: number }>() || {};
|
||||
const { count: userCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM users`
|
||||
).first<{ count: number }>() || {};
|
||||
return c.json({
|
||||
mailCount,
|
||||
addressCount,
|
||||
activeAddressCount7days,
|
||||
activeAddressCount30days,
|
||||
userCount,
|
||||
sendMailCount,
|
||||
});
|
||||
};
|
||||
|
||||
export default { get };
|
||||
@@ -1,216 +1,46 @@
|
||||
import { Context, Hono } from 'hono'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import i18n from '../i18n';
|
||||
import { getBooleanValue, getJsonSetting, checkCfTurnstile, getStringValue, getSplitStringListValue, isAddressCountLimitReached } from '../utils';
|
||||
import { newAddress, handleMailListQuery, deleteAddressWithData, getAddressPrefix, getAllowDomains, updateAddressUpdatedAt, generateRandomName } from '../common'
|
||||
import { CONSTANTS } from '../constants'
|
||||
import { resolveRawEmailRow } from '../gzip'
|
||||
import parsed_mail_api from './parsed_mail_api';
|
||||
import mails_crud from './mails_crud';
|
||||
import new_address from './new_address';
|
||||
import auto_reply from './auto_reply'
|
||||
import webhook_settings from './webhook_settings';
|
||||
import s3_attachment from './s3_attachment';
|
||||
import address_auth from './address_auth';
|
||||
import { getSendBalanceState } from './send_balance';
|
||||
|
||||
export const api = new Hono<HonoCustomType>()
|
||||
|
||||
// auto reply
|
||||
api.get('/api/auto_reply', auto_reply.getAutoReply)
|
||||
api.post('/api/auto_reply', auto_reply.saveAutoReply)
|
||||
|
||||
// webhook
|
||||
api.get('/api/webhook/settings', webhook_settings.getWebhookSettings)
|
||||
api.post('/api/webhook/settings', webhook_settings.saveWebhookSettings)
|
||||
api.post('/api/webhook/test', webhook_settings.testWebhookSettings)
|
||||
|
||||
// attachment (S3)
|
||||
api.get('/api/attachment/list', s3_attachment.list)
|
||||
api.post('/api/attachment/delete', s3_attachment.deleteKey)
|
||||
api.post('/api/attachment/put_url', s3_attachment.getSignedPutUrl)
|
||||
api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl)
|
||||
|
||||
api.get('/api/mails', async (c) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
if (!address) {
|
||||
return c.json({ "error": "No address" }, 400)
|
||||
}
|
||||
const { limit, offset } = c.req.query();
|
||||
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
|
||||
return await handleMailListQuery(c,
|
||||
`SELECT * FROM raw_mails where address = ?`,
|
||||
`SELECT count(*) as count FROM raw_mails where address = ?`,
|
||||
[address], limit, offset
|
||||
);
|
||||
})
|
||||
// mail crud
|
||||
api.get('/api/mails', mails_crud.listMails)
|
||||
api.get('/api/mail/:mail_id', mails_crud.getMail)
|
||||
api.delete('/api/mails/:id', mails_crud.deleteMail)
|
||||
|
||||
api.get('/api/mail/:mail_id', async (c) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { mail_id } = c.req.param();
|
||||
const result = await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails where id = ? and address = ?`
|
||||
).bind(mail_id, address).first();
|
||||
if (!result) return c.json(null);
|
||||
return c.json(await resolveRawEmailRow(result));
|
||||
})
|
||||
// parsed mail (server-side parsed subject/text/html/attachments)
|
||||
api.get('/api/parsed_mails', parsed_mail_api.listParsedMails)
|
||||
api.get('/api/parsed_mail/:mail_id', parsed_mail_api.getParsedMail)
|
||||
|
||||
api.delete('/api/mails/:id', async (c) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { id } = c.req.param();
|
||||
// TODO: add toLowerCase() to handle old data
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address = ? and id = ? `
|
||||
).bind(address.toLowerCase(), id).run();
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
})
|
||||
|
||||
api.get('/api/settings', async (c) => {
|
||||
const { address, address_id } = c.get("jwtPayload")
|
||||
const user_role = c.get("userRolePayload")
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (address_id && address_id > 0) {
|
||||
try {
|
||||
const db_address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where id = ? `
|
||||
).bind(address_id).first("id");
|
||||
if (!db_address_id) {
|
||||
return c.text(msgs.InvalidAddressMsg, 400)
|
||||
}
|
||||
} catch (error) {
|
||||
return c.text(msgs.InvalidAddressMsg, 400)
|
||||
}
|
||||
}
|
||||
// check address id
|
||||
try {
|
||||
if (!address_id) {
|
||||
const db_address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ? `
|
||||
).bind(address).first("id");
|
||||
if (!db_address_id) {
|
||||
return c.text(msgs.InvalidAddressMsg, 400)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return c.text(msgs.InvalidAddressMsg, 400)
|
||||
}
|
||||
|
||||
updateAddressUpdatedAt(c, address);
|
||||
|
||||
const { balance } = await getSendBalanceState(c, address);
|
||||
return c.json({
|
||||
address: address,
|
||||
send_balance: balance || 0,
|
||||
});
|
||||
})
|
||||
|
||||
api.post('/api/new_address', async (c) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const userPayload = c.get("userPayload");
|
||||
|
||||
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL)
|
||||
&& !userPayload
|
||||
) {
|
||||
return c.text(msgs.NewAddressAnonymousDisabledMsg, 403)
|
||||
}
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL)) {
|
||||
return c.text(msgs.NewAddressDisabledMsg, 403)
|
||||
}
|
||||
|
||||
// 如果启用了禁止匿名创建,且用户已登录,检查地址数量限制
|
||||
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL) && userPayload) {
|
||||
const userRole = c.get("userRolePayload");
|
||||
if (await isAddressCountLimitReached(c, userPayload.user_id, userRole)) {
|
||||
return c.text(msgs.MaxAddressCountReachedMsg, 400)
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { name, domain, cf_token, enableRandomSubdomain } = await c.req.json();
|
||||
// check cf turnstile
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
// Check if custom email names are disabled from environment variable
|
||||
const disableCustomAddressName = getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME);
|
||||
|
||||
// if no name or custom names are disabled, generate random name
|
||||
if (!name || disableCustomAddressName) {
|
||||
// Generate random name with context-based length configuration
|
||||
name = generateRandomName(c);
|
||||
}
|
||||
// check name block list
|
||||
try {
|
||||
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
|
||||
const blockList = (value || []) as string[];
|
||||
if (blockList.some((item) => name.includes(item))) {
|
||||
return c.text(`Name[${name}]is blocked`, 400)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
try {
|
||||
const addressPrefix = await getAddressPrefix(c);
|
||||
// Get client IP for source tracking
|
||||
const sourceMeta = c.req.header('CF-Connecting-IP')
|
||||
|| c.req.header('X-Forwarded-For')?.split(',')[0]?.trim()
|
||||
|| c.req.header('X-Real-IP')
|
||||
|| 'web:unknown';
|
||||
const res = await newAddress(c, {
|
||||
name, domain,
|
||||
enablePrefix: true,
|
||||
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
|
||||
checkLengthByConfig: true,
|
||||
addressPrefix,
|
||||
sourceMeta
|
||||
});
|
||||
return c.json(res);
|
||||
} catch (e) {
|
||||
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
|
||||
}
|
||||
})
|
||||
|
||||
api.delete('/api/delete_address', async (c) => {
|
||||
const { address, address_id } = c.get("jwtPayload")
|
||||
const success = await deleteAddressWithData(c, address, address_id);
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
})
|
||||
|
||||
api.delete('/api/clear_inbox', async (c) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address = ?`
|
||||
).bind(address).run();
|
||||
if (!success) {
|
||||
return c.text(msgs.FailedClearInboxMsg, 500)
|
||||
}
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
})
|
||||
|
||||
api.delete('/api/clear_sent_items', async (c) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM sendbox WHERE address = ?`
|
||||
).bind(address).run();
|
||||
if (!success) {
|
||||
return c.text(msgs.FailedClearSentItemsMsg, 500)
|
||||
}
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
})
|
||||
// address settings / lifecycle
|
||||
api.get('/api/settings', mails_crud.getSettings)
|
||||
api.post('/api/new_address', new_address.createNewAddress)
|
||||
api.delete('/api/delete_address', mails_crud.deleteAddress)
|
||||
api.delete('/api/clear_inbox', mails_crud.clearInbox)
|
||||
api.delete('/api/clear_sent_items', mails_crud.clearSentItems)
|
||||
|
||||
// address auth
|
||||
api.post('/api/address_change_password', address_auth.changePassword)
|
||||
api.post('/api/address_login', address_auth.login)
|
||||
|
||||
120
worker/src/mails_api/mails_crud.ts
Normal file
120
worker/src/mails_api/mails_crud.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Context } from 'hono'
|
||||
|
||||
import i18n from '../i18n';
|
||||
import { getBooleanValue } from '../utils';
|
||||
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
|
||||
import { resolveRawEmailRow } from '../gzip'
|
||||
import { getSendBalanceState } from './send_balance';
|
||||
|
||||
const listMails = async (c: Context<HonoCustomType>) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
if (!address) {
|
||||
return c.json({ "error": "No address" }, 400)
|
||||
}
|
||||
const { limit, offset } = c.req.query();
|
||||
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
|
||||
return await handleMailListQuery(c,
|
||||
`SELECT * FROM raw_mails where address = ?`,
|
||||
`SELECT count(*) as count FROM raw_mails where address = ?`,
|
||||
[address], limit, offset
|
||||
);
|
||||
};
|
||||
|
||||
const getMail = async (c: Context<HonoCustomType>) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { mail_id } = c.req.param();
|
||||
const result = await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails where id = ? and address = ?`
|
||||
).bind(mail_id, address).first();
|
||||
if (!result) return c.json(null);
|
||||
return c.json(await resolveRawEmailRow(result));
|
||||
};
|
||||
|
||||
const deleteMail = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { id } = c.req.param();
|
||||
// TODO: add toLowerCase() to handle old data
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address = ? and id = ? `
|
||||
).bind(address.toLowerCase(), id).run();
|
||||
return c.json({ success });
|
||||
};
|
||||
|
||||
const getSettings = async (c: Context<HonoCustomType>) => {
|
||||
const { address, address_id } = c.get("jwtPayload")
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (address_id && address_id > 0) {
|
||||
try {
|
||||
const db_address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where id = ? `
|
||||
).bind(address_id).first("id");
|
||||
if (!db_address_id) {
|
||||
return c.text(msgs.InvalidAddressMsg, 400)
|
||||
}
|
||||
} catch (error) {
|
||||
return c.text(msgs.InvalidAddressMsg, 400)
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!address_id) {
|
||||
const db_address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ? `
|
||||
).bind(address).first("id");
|
||||
if (!db_address_id) {
|
||||
return c.text(msgs.InvalidAddressMsg, 400)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return c.text(msgs.InvalidAddressMsg, 400)
|
||||
}
|
||||
|
||||
updateAddressUpdatedAt(c, address);
|
||||
|
||||
const { balance } = await getSendBalanceState(c, address);
|
||||
return c.json({
|
||||
address: address,
|
||||
send_balance: balance || 0,
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAddress = async (c: Context<HonoCustomType>) => {
|
||||
const { address, address_id } = c.get("jwtPayload")
|
||||
const success = await deleteAddressWithData(c, address, address_id);
|
||||
return c.json({ success });
|
||||
};
|
||||
|
||||
const clearInbox = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address = ?`
|
||||
).bind(address).run();
|
||||
if (!success) {
|
||||
return c.text(msgs.FailedClearInboxMsg, 500)
|
||||
}
|
||||
return c.json({ success });
|
||||
};
|
||||
|
||||
const clearSentItems = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM sendbox WHERE address = ?`
|
||||
).bind(address).run();
|
||||
if (!success) {
|
||||
return c.text(msgs.FailedClearSentItemsMsg, 500)
|
||||
}
|
||||
return c.json({ success });
|
||||
};
|
||||
|
||||
export default { listMails, getMail, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };
|
||||
74
worker/src/mails_api/new_address.ts
Normal file
74
worker/src/mails_api/new_address.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Context } from 'hono'
|
||||
|
||||
import i18n from '../i18n';
|
||||
import { getBooleanValue, getJsonSetting, checkCfTurnstile, isAddressCountLimitReached } from '../utils';
|
||||
import { newAddress, getAddressPrefix, generateRandomName } from '../common'
|
||||
import { CONSTANTS } from '../constants'
|
||||
|
||||
const createNewAddress = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const userPayload = c.get("userPayload");
|
||||
|
||||
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL)
|
||||
&& !userPayload
|
||||
) {
|
||||
return c.text(msgs.NewAddressAnonymousDisabledMsg, 403)
|
||||
}
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL)) {
|
||||
return c.text(msgs.NewAddressDisabledMsg, 403)
|
||||
}
|
||||
|
||||
// 如果启用了禁止匿名创建,且用户已登录,检查地址数量限制
|
||||
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL) && userPayload) {
|
||||
const userRole = c.get("userRolePayload");
|
||||
if (await isAddressCountLimitReached(c, userPayload.user_id, userRole)) {
|
||||
return c.text(msgs.MaxAddressCountReachedMsg, 400)
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { name, domain, cf_token, enableRandomSubdomain } = await c.req.json();
|
||||
// check cf turnstile
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
// Check if custom email names are disabled from environment variable
|
||||
const disableCustomAddressName = getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME);
|
||||
|
||||
// if no name or custom names are disabled, generate random name
|
||||
if (!name || disableCustomAddressName) {
|
||||
name = generateRandomName(c);
|
||||
}
|
||||
// check name block list
|
||||
try {
|
||||
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
|
||||
const blockList = (value || []) as string[];
|
||||
if (blockList.some((item) => name.includes(item))) {
|
||||
return c.text(`Name[${name}]is blocked`, 400)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
try {
|
||||
const addressPrefix = await getAddressPrefix(c);
|
||||
const sourceMeta = c.req.header('CF-Connecting-IP')
|
||||
|| c.req.header('X-Forwarded-For')?.split(',')[0]?.trim()
|
||||
|| c.req.header('X-Real-IP')
|
||||
|| 'web:unknown';
|
||||
const res = await newAddress(c, {
|
||||
name, domain,
|
||||
enablePrefix: true,
|
||||
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
|
||||
checkLengthByConfig: true,
|
||||
addressPrefix,
|
||||
sourceMeta
|
||||
});
|
||||
return c.json(res);
|
||||
} catch (e) {
|
||||
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
|
||||
}
|
||||
};
|
||||
|
||||
export default { createNewAddress };
|
||||
52
worker/src/mails_api/parsed_mail_api.ts
Normal file
52
worker/src/mails_api/parsed_mail_api.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Context } from 'hono'
|
||||
|
||||
import { commonParseMail, handleMailListQuery, updateAddressUpdatedAt } from '../common'
|
||||
import { resolveRawEmailRow } from '../gzip'
|
||||
|
||||
const toParsedMailRow = async (row: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
||||
const raw = typeof row.raw === 'string' ? row.raw : '';
|
||||
const parsed = raw ? await commonParseMail({ rawEmail: raw }) : undefined;
|
||||
const { raw: _raw, ...rest } = row;
|
||||
return {
|
||||
...rest,
|
||||
sender: parsed?.sender?.trim() ?? '',
|
||||
subject: parsed?.subject ?? '',
|
||||
text: parsed?.text ?? '',
|
||||
html: parsed?.html ?? '',
|
||||
attachments: (parsed?.attachments ?? []).map(a => ({
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType,
|
||||
disposition: a.disposition,
|
||||
size: a.content?.length ?? 0,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const listParsedMails = async (c: Context<HonoCustomType>) => {
|
||||
const { address } = c.get("jwtPayload");
|
||||
if (!address) return c.json({ "error": "No address" }, 400);
|
||||
const { limit, offset } = c.req.query();
|
||||
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
|
||||
const listRes = await handleMailListQuery(c,
|
||||
`SELECT * FROM raw_mails where address = ?`,
|
||||
`SELECT count(*) as count FROM raw_mails where address = ?`,
|
||||
[address], limit, offset
|
||||
);
|
||||
if (listRes.status !== 200) return listRes;
|
||||
const { results, count } = await listRes.json() as { results: Record<string, unknown>[], count: number };
|
||||
const parsed = await Promise.all(results.map(toParsedMailRow));
|
||||
return c.json({ results: parsed, count });
|
||||
};
|
||||
|
||||
const getParsedMail = async (c: Context<HonoCustomType>) => {
|
||||
const { address } = c.get("jwtPayload");
|
||||
const { mail_id } = c.req.param();
|
||||
const row = await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails where id = ? and address = ?`
|
||||
).bind(mail_id, address).first();
|
||||
if (!row) return c.json(null);
|
||||
const resolved = await resolveRawEmailRow(row);
|
||||
return c.json(await toParsedMailRow(resolved as Record<string, unknown>));
|
||||
};
|
||||
|
||||
export default { listParsedMails, getParsedMail };
|
||||
Reference in New Issue
Block a user