diff --git a/.claude/skills/cf-temp-mail-agent-mail/SKILL.md b/.claude/skills/cf-temp-mail-agent-mail/SKILL.md new file mode 100644 index 00000000..cec9de32 --- /dev/null +++ b/.claude/skills/cf-temp-mail-agent-mail/SKILL.md @@ -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": "", + "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 ` — on every `/api/*` request. +- `x-custom-auth: ` — 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 ", + "subject": "Your code is 123456", + "text": "Your code is 123456\n", + "html": "

Your code is 123456

", + "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/" -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": "

Hi

", + "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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 950de576..b03c02d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index ae0eb2a0..b8dfca16 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -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 diff --git a/README.md b/README.md index 70f1aaea..a58900c5 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/README_EN.md b/README_EN.md index 7ecf9065..b598cd7b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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) diff --git a/vitepress-docs/docs/.vitepress/en.ts b/vitepress-docs/docs/.vitepress/en.ts index a5c5ffaa..cd2e25b8 100644 --- a/vitepress-docs/docs/.vitepress/en.ts +++ b/vitepress-docs/docs/.vitepress/en.ts @@ -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' }, ] diff --git a/vitepress-docs/docs/.vitepress/zh.ts b/vitepress-docs/docs/.vitepress/zh.ts index e9877b0b..12998db7 100644 --- a/vitepress-docs/docs/.vitepress/zh.ts +++ b/vitepress-docs/docs/.vitepress/zh.ts @@ -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' }, ] diff --git a/vitepress-docs/docs/en/guide/feature/agent-email.md b/vitepress-docs/docs/en/guide/feature/agent-email.md new file mode 100644 index 00000000..19a38a9e --- /dev/null +++ b/vitepress-docs/docs/en/guide/feature/agent-email.md @@ -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": "", + "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 ", + "subject": "Your code is 123456", + "text": "Your code is 123456\n", + "html": "

Your code is 123456

", + "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 ` — required on every `/api/*` request +- `x-custom-auth: ` — 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": "

Hi

", + "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 /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 / +``` + +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. diff --git a/vitepress-docs/docs/zh/guide/feature/agent-email.md b/vitepress-docs/docs/zh/guide/feature/agent-email.md new file mode 100644 index 00000000..a2bdf1f1 --- /dev/null +++ b/vitepress-docs/docs/zh/guide/feature/agent-email.md @@ -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": "", + "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 ", + "subject": "Your code is 123456", + "text": "Your code is 123456\n", + "html": "

Your code is 123456

", + "attachments": [ + { "filename": "a.pdf", "mimeType": "application/pdf", "disposition": "attachment", "size": 12345 } + ] +} +``` + +**附件二进制不包含**在 `parsed_*` 响应里,只有元数据。需要原始字节时再退回 `/api/mail/:id` 自己解析。 + +## 必要的请求头 + +- `Authorization: Bearer ` — 所有 `/api/*` 请求必须携带 +- `x-custom-auth: ` — 仅当站点启用了私有密码 +- `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": "

Hi

", + "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 /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 / +``` + +详情见 [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` — 被限流,退避后重试 diff --git a/worker/src/admin_api/account_settings_api.ts b/worker/src/admin_api/account_settings_api.ts new file mode 100644 index 00000000..cf7acdc2 --- /dev/null +++ b/worker/src/admin_api/account_settings_api.ts @@ -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).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) => { + 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(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : []; + const emailRuleSettings = await getJsonSetting(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) => { + 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 }; diff --git a/worker/src/admin_api/address_api.ts b/worker/src/admin_api/address_api.ts new file mode 100644 index 00000000..f5039f12 --- /dev/null +++ b/worker/src/admin_api/address_api.ts @@ -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) => { + const { limit, offset, query, sort_by, sort_order } = c.req.query(); + const allowedSortColumns: Record = { + '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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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 +}; diff --git a/worker/src/admin_api/address_sender_api.ts b/worker/src/admin_api/address_sender_api.ts new file mode 100644 index 00000000..e994643a --- /dev/null +++ b/worker/src/admin_api/address_sender_api.ts @@ -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) => { + 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) => { + 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) => { + 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 }; diff --git a/worker/src/admin_api/index.ts b/worker/src/admin_api/index.ts index 72bd62d9..420b2e58 100644 --- a/worker/src/admin_api/index.ts +++ b/worker/src/admin_api/index.ts @@ -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() -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).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 = { - '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(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : []; - const emailRuleSettings = await getJsonSetting(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, sendBlockList: Array }} */ - 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) => 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) diff --git a/worker/src/admin_api/sendbox_api.ts b/worker/src/admin_api/sendbox_api.ts new file mode 100644 index 00000000..9978bc38 --- /dev/null +++ b/worker/src/admin_api/sendbox_api.ts @@ -0,0 +1,29 @@ +import { Context } from 'hono' + +import { handleListQuery } from '../common' + +const list = async (c: Context) => { + 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) => { + 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 }; diff --git a/worker/src/admin_api/statistics_api.ts b/worker/src/admin_api/statistics_api.ts new file mode 100644 index 00000000..62cb9827 --- /dev/null +++ b/worker/src/admin_api/statistics_api.ts @@ -0,0 +1,32 @@ +import { Context } from 'hono' + +const get = async (c: Context) => { + 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 }; diff --git a/worker/src/mails_api/index.ts b/worker/src/mails_api/index.ts index b5ec3251..5373864a 100644 --- a/worker/src/mails_api/index.ts +++ b/worker/src/mails_api/index.ts @@ -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() +// 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) diff --git a/worker/src/mails_api/mails_crud.ts b/worker/src/mails_api/mails_crud.ts new file mode 100644 index 00000000..34f65252 --- /dev/null +++ b/worker/src/mails_api/mails_crud.ts @@ -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) => { + 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) => { + 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) => { + 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) => { + 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) => { + const { address, address_id } = c.get("jwtPayload") + const success = await deleteAddressWithData(c, address, address_id); + return c.json({ success }); +}; + +const clearInbox = async (c: Context) => { + 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) => { + 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 }; diff --git a/worker/src/mails_api/new_address.ts b/worker/src/mails_api/new_address.ts new file mode 100644 index 00000000..a0aaa46c --- /dev/null +++ b/worker/src/mails_api/new_address.ts @@ -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) => { + 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 }; diff --git a/worker/src/mails_api/parsed_mail_api.ts b/worker/src/mails_api/parsed_mail_api.ts new file mode 100644 index 00000000..65bd4c67 --- /dev/null +++ b/worker/src/mails_api/parsed_mail_api.ts @@ -0,0 +1,52 @@ +import { Context } from 'hono' + +import { commonParseMail, handleMailListQuery, updateAddressUpdatedAt } from '../common' +import { resolveRawEmailRow } from '../gzip' + +const toParsedMailRow = async (row: Record): Promise> => { + 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) => { + 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[], count: number }; + const parsed = await Promise.all(results.map(toParsedMailRow)); + return c.json({ results: parsed, count }); +}; + +const getParsedMail = async (c: Context) => { + 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)); +}; + +export default { listParsedMails, getParsedMail };