diff --git a/.claude/skills/cf-temp-mail-usage/SKILL.md b/.claude/skills/cf-temp-mail-usage/SKILL.md new file mode 100644 index 00000000..c0a14a81 --- /dev/null +++ b/.claude/skills/cf-temp-mail-usage/SKILL.md @@ -0,0 +1,161 @@ +--- +name: cf-temp-mail-usage +description: Read 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 extract a verification code / magic link. Prefers the server-parsed endpoints so the agent gets subject/text/html/attachments directly. Does NOT handle mailbox creation — the user provides the JWT themselves. +--- + +# Temp-Mail Read-Only Usage + +Consume an existing mailbox. The user hands over the JWT (obtained in a browser after creating an address); the agent only reads mail. + +## Inputs the user must provide + +- `BASE` — API base URL, e.g. `https://mail.example.com` or the Worker's `*.workers.dev` host. +- `JWT` — Address JWT. In the frontend it is stored in `localStorage` under the key `jwt` (raw string, no JSON wrap). +- *(optional)* `SITE_PASSWORD` — only if the deployment enabled `x-custom-auth`. + +If anything is missing, ask the user before making requests. + +## 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`. + +## Endpoints (read-only) + +| 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` | +| List raw mails | GET | `/api/mails?limit=&offset=` | `{ results: [{...,raw}], count }` | +| Get one raw mail | GET | `/api/mail/:id` | `{ ..., raw }` | + +`limit` 1–100, `offset` 0-based. On `429`, back off. + +**Prefer the `parsed_*` endpoints.** They run the same `commonParseMail` (postal-mime) the frontend uses and return structured fields directly, so the agent does not need to ship a MIME parser. + +`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, fetch the raw mail via `/api/mail/:id` and parse it client-side (see below). + +## Recipes + +### 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 (parsed) + +```bash +curl -s "$BASE/api/parsed_mails?limit=20&offset=0" \ + -H "Authorization: Bearer $JWT" +``` + +### 3. Get one mail (parsed) + +```bash +curl -s "$BASE/api/parsed_mail/" -H "Authorization: Bearer $JWT" +``` + +### 4. Extract a verification code (end-to-end, parsed API) + +```python +import re, time, requests + +BASE, JWT = "", "" +H = {"Authorization": f"Bearer {JWT}"} + +def wait_for_code(pattern=r"\b\d{4,8}\b", timeout=120, poll=3): + deadline = time.time() + timeout + seen = set() + while time.time() < deadline: + lst = requests.get(f"{BASE}/api/parsed_mails?limit=5&offset=0", headers=H).json() + for m in lst.get("results", []): + if m["id"] in seen: continue + seen.add(m["id"]) + body = (m.get("subject") or "") + "\n" + (m.get("text") or "") + "\n" + (m.get("html") or "") + hit = re.search(pattern, body) + if hit: + return hit.group(0) + time.sleep(poll) + raise TimeoutError("no matching mail within window") + +print(wait_for_code()) +``` + +## Raw endpoints (fallback — only if you need attachment bytes or the original MIME) + +`/api/mails` and `/api/mail/:id` return the gzip-resolved RFC822 source in `raw`. Parse it client-side. + +### Node.js (postal-mime, pure JS) + +```bash +npm i postal-mime +``` + +```js +import PostalMime from 'postal-mime'; + +const mail = await (await fetch(`${BASE}/api/mail/${id}`, { + headers: { Authorization: `Bearer ${JWT}` }, +})).json(); +const parsed = await PostalMime.parse(mail.raw); +// parsed.subject / parsed.from / parsed.text / parsed.html +// parsed.attachments[i].content is a Uint8Array +``` + +### Python (stdlib, no deps) + +```python +import email, requests +from email import policy + +r = requests.get(f"{BASE}/api/mail/{mid}", headers={"Authorization": f"Bearer {JWT}"}).json() +msg = email.message_from_string(r["raw"], policy=policy.default) +subject = msg["subject"] +text = (msg.get_body(preferencelist=("plain",)) or None) and msg.get_body(preferencelist=("plain",)).get_content() +html = (msg.get_body(preferencelist=("html",)) or None) and msg.get_body(preferencelist=("html",)).get_content() +for part in msg.iter_attachments(): + name, mime, data = part.get_filename(), part.get_content_type(), part.get_content() +``` + +The frontend's reference implementation is `frontend/src/utils/email-parser.js` — tries `mail-parser-wasm` first, falls back to `postal-mime`. The server uses `postal-mime` only. + +## 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`. +- `429` — rate limited; back off. diff --git a/CHANGELOG.md b/CHANGELOG.md index 950de576..d2e3a844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,15 @@ ### 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-usage`(`.claude/skills/cf-temp-mail-usage/`),让 OpenClaw / Codex / Cursor 等 AI agent 凭用户提供的 Address JWT + API 地址读取邮箱、轮询验证码,绕开创建邮箱时的 Turnstile 人机验证;可通过 `npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage` 安装 + ### 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..715ce36f 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -10,10 +10,15 @@ ### 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-usage` (`.claude/skills/cf-temp-mail-usage/`) 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-usage` + ### 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..6ec5bc87 100644 --- a/README.md +++ b/README.md @@ -150,9 +150,26 @@ - [x] Webhook 支持,消息推送集成 - [x] 支持 `CF Turnstile` 人机验证 - [x] 限流配置,防止滥用 +- [x] **Agent 友好**:提供服务端解析的 `/api/parsed_mails` / `/api/parsed_mail/:id`,配合仓库内的 `cf-temp-mail-usage` skill,OpenClaw / Codex / Cursor 等 AI agent 可直接使用用户提供的 JWT 读取验证码 / 链接,无需在客户端引入 MIME 解析器 +## 给 AI Agent 使用:`cf-temp-mail-usage` skill + +仓库内置一个只读 skill:`.claude/skills/cf-temp-mail-usage/`,让 AI agent 用用户提供的 `Address JWT + API 地址`直接消费邮箱(列出邮件 / 取单封 / 轮询验证码),规避前端创建邮箱时的 Turnstile 人机验证。 + +安装到当前项目的 Claude Code: + +```bash +# 方式 1:degit 拷贝子目录 +npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage .claude/skills/cf-temp-mail-usage + +# 方式 2:安装到全局 +npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage ~/.claude/skills/cf-temp-mail-usage +``` + +细节见 [.claude/skills/cf-temp-mail-usage/SKILL.md](.claude/skills/cf-temp-mail-usage/SKILL.md)。 + ## 技术架构
diff --git a/README_EN.md b/README_EN.md index 7ecf9065..50cbabc9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -150,9 +150,26 @@ 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**: server-side parsed endpoints `/api/parsed_mails` / `/api/parsed_mail/:id`, plus the bundled `cf-temp-mail-usage` skill, let AI agents like OpenClaw / Codex / Cursor consume a mailbox with a user-supplied JWT to read verification codes / magic links — no client-side MIME parser needed, and it sidesteps the Turnstile challenge on mailbox creation
+## For AI Agents: `cf-temp-mail-usage` skill + +A read-only skill is bundled at `.claude/skills/cf-temp-mail-usage/`. It lets an AI agent consume a mailbox using a user-supplied `Address JWT + API base URL` (list mails / fetch one / poll for verification codes), bypassing the Turnstile challenge required to create a mailbox in the UI. + +Install into a project's Claude Code: + +```bash +# Option 1: degit the sub-directory into the current project +npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage .claude/skills/cf-temp-mail-usage + +# Option 2: install globally for all projects +npx degit dreamhunter2333/cloudflare_temp_email/.claude/skills/cf-temp-mail-usage ~/.claude/skills/cf-temp-mail-usage +``` + +See [.claude/skills/cf-temp-mail-usage/SKILL.md](.claude/skills/cf-temp-mail-usage/SKILL.md) for details. + ## Technical Architecture
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..e1ada2ee --- /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 ?? '', + 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 };