Compare commits

...

1 Commits

Author SHA1 Message Date
dreamhunter2333
2e32cf472b feat: add cf-temp-mail-usage skill and parsed mail API for AI agents
- feat: new /api/parsed_mails and /api/parsed_mail/:id endpoints returning
  server-parsed subject/text/html/attachments metadata (reuses commonParseMail)
- feat: add .claude/skills/cf-temp-mail-usage read-only skill so AI agents
  (OpenClaw / Codex / Cursor) can consume a mailbox with a user-supplied JWT,
  bypassing the Turnstile challenge required for mailbox creation
- refactor: split mails_api/index.ts and admin_api/index.ts into thin route
  shells; move business logic into dedicated *_api.ts files
- docs: update README / README_EN / CHANGELOG with agent-email feature and
  npx degit install instructions for the skill

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 13:36:15 +08:00
15 changed files with 927 additions and 631 deletions

View File

@@ -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 <JWT>` — on every `/api/*` request.
- `x-custom-auth: <SITE_PASSWORD>` — only when the site requires it.
- `x-lang: en` or `zh` — optional, error-message language.
Do not send the Address JWT as `x-user-token` — that is a different JWT type and will yield `401 InvalidAddressCredentialMsg`.
## 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` 1100, `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 <noreply@foo.com>",
"subject": "Your code is 123456",
"text": "Your code is 123456\n",
"html": "<p>Your code is <b>123456</b></p>",
"attachments": [
{ "filename": "a.pdf", "mimeType": "application/pdf", "disposition": "attachment", "size": 12345 }
]
}
```
Attachment **binary content is not included** in `parsed_*` responses — only metadata. If you need the bytes, 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/<id>" -H "Authorization: Bearer $JWT"
```
### 4. Extract a verification code (end-to-end, parsed API)
```python
import re, time, requests
BASE, JWT = "<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.

View File

@@ -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

View File

@@ -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

View File

@@ -150,9 +150,26 @@
- [x] Webhook 支持,消息推送集成
- [x] 支持 `CF Turnstile` 人机验证
- [x] 限流配置,防止滥用
- [x] **Agent 友好**:提供服务端解析的 `/api/parsed_mails` / `/api/parsed_mail/:id`,配合仓库内的 `cf-temp-mail-usage` skillOpenClaw / Codex / Cursor 等 AI agent 可直接使用用户提供的 JWT 读取验证码 / 链接,无需在客户端引入 MIME 解析器
</details>
## 给 AI Agent 使用:`cf-temp-mail-usage` skill
仓库内置一个只读 skill`.claude/skills/cf-temp-mail-usage/`,让 AI agent 用用户提供的 `Address JWT + API 地址`直接消费邮箱(列出邮件 / 取单封 / 轮询验证码),规避前端创建邮箱时的 Turnstile 人机验证。
安装到当前项目的 Claude Code
```bash
# 方式 1degit 拷贝子目录
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)。
## 技术架构
<details>

View File

@@ -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
</details>
## 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
<details>

View File

@@ -0,0 +1,133 @@
import { Context } from 'hono'
import i18n from '../i18n'
import { getJsonSetting, saveSetting } from '../utils'
import { getAddressCreationSettings, getAddressCreationSubdomainMatchStatus } from '../common'
import { CONSTANTS } from '../constants'
import {
getSendMailLimitConfig,
getSendMailLimitConfigToSave,
validateSendMailLimitConfig
} from '../mails_api/send_mail_limit_utils'
import { EmailRuleSettings } from '../models'
const normalizeAddressCreationSettingsUpdate = (
value: unknown
): {
shouldUpdate: boolean,
shouldClear: boolean,
nextEnableSubdomainMatch?: boolean,
} | null => {
if (typeof value === 'undefined') {
return { shouldUpdate: false, shouldClear: false };
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const nextEnableSubdomainMatch = (value as Record<string, unknown>).enableSubdomainMatch;
if (typeof nextEnableSubdomainMatch === 'undefined') {
return { shouldUpdate: false, shouldClear: false };
}
// null 代表"清空后台覆盖,恢复为未设置并回退到 env",这是给前端三态显式使用的正式路径。
if (nextEnableSubdomainMatch === null) {
return { shouldUpdate: true, shouldClear: true };
}
if (typeof nextEnableSubdomainMatch !== 'boolean') {
return null;
}
return {
shouldUpdate: true,
shouldClear: false,
nextEnableSubdomainMatch,
};
};
const get = async (c: Context<HonoCustomType>) => {
try {
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY);
const fromBlockList = c.env.KV ? await c.env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : [];
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY);
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
const addressCreationSettings = await getAddressCreationSettings(c);
const addressCreationSubdomainMatchStatus = await getAddressCreationSubdomainMatchStatus(c, addressCreationSettings);
const sendMailLimitConfig = await getSendMailLimitConfig(c);
return c.json({
blockList: blockList || [],
sendBlockList: sendBlockList || [],
verifiedAddressList: verifiedAddressList || [],
fromBlockList: fromBlockList || [],
noLimitSendAddressList: noLimitSendAddressList || [],
emailRuleSettings: emailRuleSettings || {},
addressCreationSettings: typeof addressCreationSettings.enableSubdomainMatch === 'boolean'
? { enableSubdomainMatch: addressCreationSettings.enableSubdomainMatch }
: {},
addressCreationSubdomainMatchStatus,
sendMailLimitConfig,
})
} catch (error) {
console.error(error);
return c.json({})
}
};
const save = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const {
blockList, sendBlockList, noLimitSendAddressList,
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings,
sendMailLimitConfig
} = await c.req.json();
if (!blockList || !sendBlockList || !verifiedAddressList) {
return c.text(msgs.InvalidInputMsg, 400)
}
const addressCreationSettingsUpdate = normalizeAddressCreationSettingsUpdate(addressCreationSettings);
if (!addressCreationSettingsUpdate) {
return c.text(msgs.InvalidInputMsg, 400)
}
if (!c.env.SEND_MAIL && verifiedAddressList.length > 0) {
return c.text(msgs.EnableSendMailMsg, 400)
}
// 所有输入依赖都先校验,再执行任意写入,避免接口返回 400 时出现部分设置已落库的半成功状态。
if (fromBlockList?.length > 0 && !c.env.KV) {
return c.text(msgs.EnableKVMsg, 400)
}
if (sendMailLimitConfig && !validateSendMailLimitConfig(sendMailLimitConfig)) {
return c.text(msgs.InvalidInputMsg, 400)
}
const sendMailLimitConfigToSave = sendMailLimitConfig
? getSendMailLimitConfigToSave(sendMailLimitConfig)
: null;
await saveSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY, JSON.stringify(blockList));
await saveSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY, JSON.stringify(sendBlockList));
await saveSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY, JSON.stringify(verifiedAddressList));
if (fromBlockList?.length > 0 && c.env.KV) {
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList))
}
await saveSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY, JSON.stringify(noLimitSendAddressList || []));
await saveSetting(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY, JSON.stringify(emailRuleSettings || {}));
if (addressCreationSettingsUpdate.shouldUpdate) {
if (addressCreationSettingsUpdate.shouldClear) {
await c.env.DB.prepare(
`DELETE FROM settings WHERE key = ?`
).bind(CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY).run();
} else {
await saveSetting(
c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY,
JSON.stringify({
enableSubdomainMatch: addressCreationSettingsUpdate.nextEnableSubdomainMatch
})
)
}
}
if (sendMailLimitConfigToSave) {
await saveSetting(
c, CONSTANTS.SEND_MAIL_LIMIT_CONFIG_KEY,
JSON.stringify(sendMailLimitConfigToSave)
)
}
return c.json({ success: true });
};
export default { get, save };

View File

@@ -0,0 +1,159 @@
import { Context } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n'
import { getBooleanValue, hashPassword } from '../utils'
import { newAddress, handleListQuery } from '../common'
const listAddresses = async (c: Context<HonoCustomType>) => {
const { limit, offset, query, sort_by, sort_order } = c.req.query();
const allowedSortColumns: Record<string, string> = {
'id': 'a.id',
'name': 'a.name',
'created_at': 'a.created_at',
'updated_at': 'a.updated_at',
'source_meta': 'a.source_meta',
'mail_count': 'mail_count',
'send_count': 'send_count',
};
const sortColumn = Object.hasOwn(allowedSortColumns, sort_by) ? allowedSortColumns[sort_by] : 'a.id';
const sortDirection = sort_order === 'ascend' ? 'asc' : 'desc';
const orderBy = `${sortColumn} ${sortDirection}`;
if (query) {
// D1 caps LIKE pattern length at 50 bytes; fall back to instr() for
// longer queries to avoid "LIKE or GLOB pattern too complex" (#956).
const useInstr = new TextEncoder().encode(query).length + 2 > 50;
const whereClause = useInstr ? `instr(name, ?) > 0` : `name like ?`;
const param = useInstr ? query : `%${query}%`;
return await handleListQuery(c,
`SELECT a.*,`
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a`
+ ` where ${whereClause}`,
`SELECT count(*) as count FROM address where ${whereClause}`,
[param], limit, offset, orderBy
);
}
return await handleListQuery(c,
`SELECT a.*,`
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a`,
`SELECT count(*) as count FROM address`,
[], limit, offset, orderBy
);
};
const createNewAddress = async (c: Context<HonoCustomType>) => {
const { name, domain, enablePrefix, enableRandomSubdomain } = await c.req.json();
const msgs = i18n.getMessagesbyContext(c);
if (!name) {
return c.text(msgs.RequiredFieldMsg, 400)
}
try {
const res = await newAddress(c, {
name, domain, enablePrefix,
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
checkLengthByConfig: false,
addressPrefix: null,
checkAllowDomains: false,
enableCheckNameRegex: false,
sourceMeta: 'admin'
});
return c.json(res);
} catch (e) {
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
}
};
const deleteAddress = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM address WHERE id = ? `
).bind(id).run();
if (!success) {
return c.text(msgs.OperationFailedMsg, 500)
}
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
if (!mailSuccess) {
return c.text(msgs.OperationFailedMsg, 500)
}
const { success: sendAccess } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
const { success: usersAddressSuccess } = await c.env.DB.prepare(
`DELETE FROM users_address WHERE address_id = ?`
).bind(id).run();
return c.json({
success: success && mailSuccess && sendAccess && usersAddressSuccess
})
};
const clearInbox = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param();
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
if (!mailSuccess) {
return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({ success: mailSuccess });
};
const clearSentItems = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param();
const { success: sendboxSuccess } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
if (!sendboxSuccess) {
return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({ success: sendboxSuccess });
};
const showPassword = async (c: Context<HonoCustomType>) => {
const { id } = c.req.param();
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(id).first("name");
const jwt = await Jwt.sign({
address: name,
address_id: id
}, c.env.JWT_SECRET, "HS256")
return c.json({ jwt });
};
const resetPassword = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param();
const { password } = await c.req.json();
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
return c.text(msgs.PasswordChangeDisabledMsg, 403);
}
if (!password) {
return c.text(msgs.NewPasswordRequiredMsg, 400);
}
const hashedPassword = await hashPassword(password);
const { success } = await c.env.DB.prepare(
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?`
).bind(hashedPassword, id).run();
if (!success) {
return c.text(msgs.FailedUpdatePasswordMsg, 500);
}
return c.json({ success: true });
};
export default {
listAddresses, createNewAddress, deleteAddress, clearInbox, clearSentItems,
showPassword, resetPassword
};

View File

@@ -0,0 +1,53 @@
import { Context } from 'hono'
import i18n from '../i18n'
import { sendAdminInternalMail } from '../utils'
import { handleListQuery } from '../common'
const list = async (c: Context<HonoCustomType>) => {
const { address, limit, offset } = c.req.query();
if (address) {
return await handleListQuery(c,
`SELECT * FROM address_sender where address = ? `,
`SELECT count(*) as count FROM address_sender where address = ? `,
[address], limit, offset
);
}
return await handleListQuery(c,
`SELECT * FROM address_sender `,
`SELECT count(*) as count FROM address_sender `,
[], limit, offset
);
};
const update = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
/* eslint-disable prefer-const */
let { address, address_id, balance, enabled } = await c.req.json();
/* eslint-enable prefer-const */
if (!address_id) {
return c.text(msgs.InvalidAddressIdMsg, 400)
}
enabled = enabled ? 1 : 0;
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET enabled = ?, balance = ? WHERE id = ? `
).bind(enabled, balance, address_id).run();
if (!success) {
return c.text(msgs.OperationFailedMsg, 500)
}
await sendAdminInternalMail(
c, address, "Account Send Access Updated",
`Your send access has been ${enabled ? "enabled" : "disabled"}, balance: ${balance}`
);
return c.json({ success });
};
const remove = async (c: Context<HonoCustomType>) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE id = ? `
).bind(id).run();
return c.json({ success });
};
export default { list, update, remove };

View File

@@ -1,10 +1,11 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { Context, Hono } from 'hono'
import i18n from '../i18n'
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles, getBooleanValue, hashPassword } from '../utils'
import { newAddress, handleListQuery, getAddressCreationSettings, getAddressCreationSubdomainMatchStatus } from '../common'
import { CONSTANTS } from '../constants'
import { getUserRoles } from '../utils'
import address_api from './address_api'
import address_sender_api from './address_sender_api'
import sendbox_api from './sendbox_api'
import statistics_api from './statistics_api'
import account_settings_api from './account_settings_api'
import cleanup_api from './cleanup_api'
import admin_user_api from './admin_user_api'
import webhook_settings from './webhook_settings'
@@ -13,434 +14,42 @@ import oauth2_settings from './oauth2_settings'
import worker_config from './worker_config'
import admin_mail_api from './admin_mail_api'
import { sendMailbyAdmin, sendMailByBindingAdmin } from './send_mail'
import {
getSendMailLimitConfig,
getSendMailLimitConfigToSave,
validateSendMailLimitConfig
} from '../mails_api/send_mail_limit_utils'
import db_api from './db_api'
import ip_blacklist_settings from './ip_blacklist_settings'
import ai_extract_settings from './ai_extract_settings'
import { EmailRuleSettings } from '../models'
import e2e_test_api from './e2e_test_api'
export const api = new Hono<HonoCustomType>()
const normalizeAddressCreationSettingsUpdate = (
value: unknown
): {
shouldUpdate: boolean,
shouldClear: boolean,
nextEnableSubdomainMatch?: boolean,
} | null => {
if (typeof value === 'undefined') {
return {
shouldUpdate: false,
shouldClear: false,
};
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const nextEnableSubdomainMatch = (value as Record<string, unknown>).enableSubdomainMatch;
if (typeof nextEnableSubdomainMatch === 'undefined') {
return {
shouldUpdate: false,
shouldClear: false,
};
}
// null 代表“清空后台覆盖,恢复为未设置并回退到 env”这是给前端三态显式使用的正式路径。
if (nextEnableSubdomainMatch === null) {
return {
shouldUpdate: true,
shouldClear: true,
};
}
if (typeof nextEnableSubdomainMatch !== 'boolean') {
return null;
}
return {
shouldUpdate: true,
shouldClear: false,
nextEnableSubdomainMatch,
};
}
api.get('/admin/address', async (c) => {
const { limit, offset, query, sort_by, sort_order } = c.req.query();
const allowedSortColumns: Record<string, string> = {
'id': 'a.id',
'name': 'a.name',
'created_at': 'a.created_at',
'updated_at': 'a.updated_at',
'source_meta': 'a.source_meta',
'mail_count': 'mail_count',
'send_count': 'send_count',
};
const sortColumn = Object.hasOwn(allowedSortColumns, sort_by) ? allowedSortColumns[sort_by] : 'a.id';
const sortDirection = sort_order === 'ascend' ? 'asc' : 'desc';
const orderBy = `${sortColumn} ${sortDirection}`;
if (query) {
// D1 caps LIKE pattern length at 50 bytes; fall back to instr() for
// longer queries to avoid "LIKE or GLOB pattern too complex" (#956).
const useInstr = new TextEncoder().encode(query).length + 2 > 50;
const whereClause = useInstr ? `instr(name, ?) > 0` : `name like ?`;
const param = useInstr ? query : `%${query}%`;
return await handleListQuery(c,
`SELECT a.*,`
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a`
+ ` where ${whereClause}`,
`SELECT count(*) as count FROM address where ${whereClause}`,
[param], limit, offset, orderBy
);
}
return await handleListQuery(c,
`SELECT a.*,`
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a`,
`SELECT count(*) as count FROM address`,
[], limit, offset, orderBy
);
})
api.post('/admin/new_address', async (c) => {
const { name, domain, enablePrefix, enableRandomSubdomain } = await c.req.json();
const msgs = i18n.getMessagesbyContext(c);
if (!name) {
return c.text(msgs.RequiredFieldMsg, 400)
}
try {
const res = await newAddress(c, {
name, domain, enablePrefix,
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
checkLengthByConfig: false,
addressPrefix: null,
checkAllowDomains: false,
enableCheckNameRegex: false,
sourceMeta: 'admin'
});
return c.json(res);
} catch (e) {
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
}
})
api.delete('/admin/delete_address/:id', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM address WHERE id = ? `
).bind(id).run();
if (!success) {
return c.text(msgs.OperationFailedMsg, 500)
}
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
if (!mailSuccess) {
return c.text(msgs.OperationFailedMsg, 500)
}
const { success: sendAccess } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
const { success: usersAddressSuccess } = await c.env.DB.prepare(
`DELETE FROM users_address WHERE address_id = ?`
).bind(id).run();
return c.json({
success: success && mailSuccess && sendAccess && usersAddressSuccess
})
})
api.delete('/admin/clear_inbox/:id', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param();
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
if (!mailSuccess) {
return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({
success: mailSuccess
})
})
api.delete('/admin/clear_sent_items/:id', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param();
const { success: sendboxSuccess } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
if (!sendboxSuccess) {
return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({
success: sendboxSuccess
})
})
api.get('/admin/show_password/:id', async (c) => {
const { id } = c.req.param();
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(id).first("name");
const jwt = await Jwt.sign({
address: name,
address_id: id
}, c.env.JWT_SECRET, "HS256")
return c.json({
jwt: jwt
})
})
api.post('/admin/address/:id/reset_password', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param();
const { password } = await c.req.json();
// 检查功能是否启用
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
return c.text(msgs.PasswordChangeDisabledMsg, 403);
}
if (!password) {
return c.text(msgs.NewPasswordRequiredMsg, 400);
}
const hashedPassword = await hashPassword(password);
const { success } = await c.env.DB.prepare(
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?`
).bind(hashedPassword, id).run();
if (!success) {
return c.text(msgs.FailedUpdatePasswordMsg, 500);
}
return c.json({ success: true });
})
// address
api.get('/admin/address', address_api.listAddresses)
api.post('/admin/new_address', address_api.createNewAddress)
api.delete('/admin/delete_address/:id', address_api.deleteAddress)
api.delete('/admin/clear_inbox/:id', address_api.clearInbox)
api.delete('/admin/clear_sent_items/:id', address_api.clearSentItems)
api.get('/admin/show_password/:id', address_api.showPassword)
api.post('/admin/address/:id/reset_password', address_api.resetPassword)
// mail api
api.get('/admin/mails', admin_mail_api.getMails);
api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails);
api.get('/admin/mails', admin_mail_api.getMails)
api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails)
api.delete('/admin/mails/:id', admin_mail_api.deleteMail)
api.get('/admin/address_sender', async (c) => {
const { address, limit, offset } = c.req.query();
if (address) {
return await handleListQuery(c,
`SELECT * FROM address_sender where address = ? `,
`SELECT count(*) as count FROM address_sender where address = ? `,
[address], limit, offset
);
}
return await handleListQuery(c,
`SELECT * FROM address_sender `,
`SELECT count(*) as count FROM address_sender `,
[], limit, offset
);
})
// address sender
api.get('/admin/address_sender', address_sender_api.list)
api.post('/admin/address_sender', address_sender_api.update)
api.delete('/admin/address_sender/:id', address_sender_api.remove)
api.post('/admin/address_sender', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
/* eslint-disable prefer-const */
let { address, address_id, balance, enabled } = await c.req.json();
/* eslint-enable prefer-const */
if (!address_id) {
return c.text(msgs.InvalidAddressIdMsg, 400)
}
enabled = enabled ? 1 : 0;
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET enabled = ?, balance = ? WHERE id = ? `
).bind(enabled, balance, address_id).run();
if (!success) {
return c.text(msgs.OperationFailedMsg, 500)
}
await sendAdminInternalMail(
c, address, "Account Send Access Updated",
`Your send access has been ${enabled ? "enabled" : "disabled"}, balance: ${balance}`
);
return c.json({
success: success
})
})
// sendbox
api.get('/admin/sendbox', sendbox_api.list)
api.delete('/admin/sendbox/:id', sendbox_api.remove)
api.delete('/admin/address_sender/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE id = ? `
).bind(id).run();
return c.json({
success: success
})
})
// statistics
api.get('/admin/statistics', statistics_api.get)
api.get('/admin/sendbox', async (c) => {
const { address, limit, offset } = c.req.query();
if (address) {
return await handleListQuery(c,
`SELECT * FROM sendbox where address = ? `,
`SELECT count(*) as count FROM sendbox where address = ? `,
[address], limit, offset
);
}
return await handleListQuery(c,
`SELECT * FROM sendbox `,
`SELECT count(*) as count FROM sendbox `,
[], limit, offset
);
})
api.delete('/admin/sendbox/:id', async (c) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE id = ? `
).bind(id).run();
return c.json({
success: success
})
})
api.get('/admin/statistics', async (c) => {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM raw_mails`
).first<{ count: number }>() || {};
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address`
).first<{ count: number }>() || {};
const { count: activeAddressCount7days } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
).first<{ count: number }>() || {};
const { count: activeAddressCount30days } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-30 day')`
).first<{ count: number }>() || {};
const { count: sendMailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM sendbox`
).first<{ count: number }>() || {};
const { count: userCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM users`
).first<{ count: number }>() || {};
return c.json({
mailCount: mailCount,
addressCount: addressCount,
activeAddressCount7days: activeAddressCount7days,
activeAddressCount30days: activeAddressCount30days,
userCount: userCount,
sendMailCount: sendMailCount
})
});
api.get('/admin/account_settings', async (c) => {
try {
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY);
const fromBlockList = c.env.KV ? await c.env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : [];
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY);
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
const addressCreationSettings = await getAddressCreationSettings(c);
const addressCreationSubdomainMatchStatus = await getAddressCreationSubdomainMatchStatus(c, addressCreationSettings);
const sendMailLimitConfig = await getSendMailLimitConfig(c);
return c.json({
blockList: blockList || [],
sendBlockList: sendBlockList || [],
verifiedAddressList: verifiedAddressList || [],
fromBlockList: fromBlockList || [],
noLimitSendAddressList: noLimitSendAddressList || [],
emailRuleSettings: emailRuleSettings || {},
addressCreationSettings: typeof addressCreationSettings.enableSubdomainMatch === 'boolean'
? { enableSubdomainMatch: addressCreationSettings.enableSubdomainMatch }
: {},
addressCreationSubdomainMatchStatus,
sendMailLimitConfig,
})
} catch (error) {
console.error(error);
return c.json({})
}
})
api.post('/admin/account_settings', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
const {
blockList, sendBlockList, noLimitSendAddressList,
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings,
sendMailLimitConfig
} = await c.req.json();
if (!blockList || !sendBlockList || !verifiedAddressList) {
return c.text(msgs.InvalidInputMsg, 400)
}
const addressCreationSettingsUpdate = normalizeAddressCreationSettingsUpdate(addressCreationSettings);
if (!addressCreationSettingsUpdate) {
return c.text(msgs.InvalidInputMsg, 400)
}
if (!c.env.SEND_MAIL && verifiedAddressList.length > 0) {
return c.text(msgs.EnableSendMailMsg, 400)
}
// 所有输入依赖都先校验,再执行任意写入,避免接口返回 400 时出现部分设置已落库的半成功状态。
if (fromBlockList?.length > 0 && !c.env.KV) {
return c.text(msgs.EnableKVMsg, 400)
}
if (sendMailLimitConfig && !validateSendMailLimitConfig(sendMailLimitConfig)) {
return c.text(msgs.InvalidInputMsg, 400)
}
const sendMailLimitConfigToSave = sendMailLimitConfig
? getSendMailLimitConfigToSave(sendMailLimitConfig)
: null;
await saveSetting(
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
JSON.stringify(blockList)
);
await saveSetting(
c, CONSTANTS.SEND_BLOCK_LIST_KEY,
JSON.stringify(sendBlockList)
);
await saveSetting(
c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY,
JSON.stringify(verifiedAddressList)
)
if (fromBlockList?.length > 0 && c.env.KV) {
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList))
}
await saveSetting(
c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY,
JSON.stringify(noLimitSendAddressList || [])
)
await saveSetting(
c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY,
JSON.stringify(emailRuleSettings || {})
)
if (addressCreationSettingsUpdate.shouldUpdate) {
if (addressCreationSettingsUpdate.shouldClear) {
await c.env.DB.prepare(
`DELETE FROM settings WHERE key = ?`
).bind(CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY).run();
} else {
await saveSetting(
c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY,
JSON.stringify({
enableSubdomainMatch: addressCreationSettingsUpdate.nextEnableSubdomainMatch
})
)
}
}
if (sendMailLimitConfigToSave) {
await saveSetting(
c, CONSTANTS.SEND_MAIL_LIMIT_CONFIG_KEY,
JSON.stringify(sendMailLimitConfigToSave)
)
}
return c.json({
success: true
})
})
// account settings
api.get('/admin/account_settings', account_settings_api.get)
api.post('/admin/account_settings', account_settings_api.save)
// cleanup
api.post('/admin/cleanup', cleanup_api.cleanup)
@@ -454,7 +63,7 @@ api.get('/admin/users', admin_user_api.getUsers)
api.delete('/admin/users/:user_id', admin_user_api.deleteUser)
api.post('/admin/users', admin_user_api.createUser)
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
api.get('/admin/user_roles', async (c) => c.json(getUserRoles(c)))
api.get('/admin/user_roles', async (c: Context<HonoCustomType>) => c.json(getUserRoles(c)))
api.post('/admin/user_roles', admin_user_api.updateUserRoles)
api.get('/admin/role_address_config', admin_user_api.getRoleAddressConfig)
api.post('/admin/role_address_config', admin_user_api.saveRoleAddressConfig)
@@ -466,34 +75,34 @@ api.get('/admin/user_oauth2_settings', oauth2_settings.getUserOauth2Settings)
api.post('/admin/user_oauth2_settings', oauth2_settings.saveUserOauth2Settings)
// webhook settings
api.get("/admin/webhook/settings", webhook_settings.getWebhookSettings);
api.post("/admin/webhook/settings", webhook_settings.saveWebhookSettings);
api.get('/admin/webhook/settings', webhook_settings.getWebhookSettings)
api.post('/admin/webhook/settings', webhook_settings.saveWebhookSettings)
// mail webhook settings
api.get("/admin/mail_webhook/settings", mail_webhook_settings.getWebhookSettings);
api.post("/admin/mail_webhook/settings", mail_webhook_settings.saveWebhookSettings);
api.post("/admin/mail_webhook/test", mail_webhook_settings.testWebhookSettings);
api.get('/admin/mail_webhook/settings', mail_webhook_settings.getWebhookSettings)
api.post('/admin/mail_webhook/settings', mail_webhook_settings.saveWebhookSettings)
api.post('/admin/mail_webhook/test', mail_webhook_settings.testWebhookSettings)
// worker config
api.get("/admin/worker/configs", worker_config.getConfig);
api.get('/admin/worker/configs', worker_config.getConfig)
// send mail by admin
api.post("/admin/send_mail", sendMailbyAdmin);
api.post("/admin/send_mail_by_binding", sendMailByBindingAdmin);
api.post('/admin/send_mail', sendMailbyAdmin)
api.post('/admin/send_mail_by_binding', sendMailByBindingAdmin)
// db api
api.get('admin/db_version', db_api.getVersion);
api.post('admin/db_initialize', db_api.initialize);
api.post('admin/db_migration', db_api.migrate);
api.get('admin/db_version', db_api.getVersion)
api.post('admin/db_initialize', db_api.initialize)
api.post('admin/db_migration', db_api.migrate)
// IP blacklist settings
api.get("/admin/ip_blacklist/settings", ip_blacklist_settings.getIpBlacklistSettings);
api.post("/admin/ip_blacklist/settings", ip_blacklist_settings.saveIpBlacklistSettings);
api.get('/admin/ip_blacklist/settings', ip_blacklist_settings.getIpBlacklistSettings)
api.post('/admin/ip_blacklist/settings', ip_blacklist_settings.saveIpBlacklistSettings)
// AI extract settings
api.get("/admin/ai_extract/settings", ai_extract_settings.getAiExtractSettings);
api.post("/admin/ai_extract/settings", ai_extract_settings.saveAiExtractSettings);
api.get('/admin/ai_extract/settings', ai_extract_settings.getAiExtractSettings)
api.post('/admin/ai_extract/settings', ai_extract_settings.saveAiExtractSettings)
// E2E test endpoints
api.post('/admin/test/seed_mail', e2e_test_api.seedMail);
api.post('/admin/test/receive_mail', e2e_test_api.receiveMail);
api.post('/admin/test/seed_mail', e2e_test_api.seedMail)
api.post('/admin/test/receive_mail', e2e_test_api.receiveMail)

View File

@@ -0,0 +1,29 @@
import { Context } from 'hono'
import { handleListQuery } from '../common'
const list = async (c: Context<HonoCustomType>) => {
const { address, limit, offset } = c.req.query();
if (address) {
return await handleListQuery(c,
`SELECT * FROM sendbox where address = ? `,
`SELECT count(*) as count FROM sendbox where address = ? `,
[address], limit, offset
);
}
return await handleListQuery(c,
`SELECT * FROM sendbox `,
`SELECT count(*) as count FROM sendbox `,
[], limit, offset
);
};
const remove = async (c: Context<HonoCustomType>) => {
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE id = ? `
).bind(id).run();
return c.json({ success });
};
export default { list, remove };

View File

@@ -0,0 +1,32 @@
import { Context } from 'hono'
const get = async (c: Context<HonoCustomType>) => {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM raw_mails`
).first<{ count: number }>() || {};
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address`
).first<{ count: number }>() || {};
const { count: activeAddressCount7days } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
).first<{ count: number }>() || {};
const { count: activeAddressCount30days } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-30 day')`
).first<{ count: number }>() || {};
const { count: sendMailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM sendbox`
).first<{ count: number }>() || {};
const { count: userCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM users`
).first<{ count: number }>() || {};
return c.json({
mailCount,
addressCount,
activeAddressCount7days,
activeAddressCount30days,
userCount,
sendMailCount,
});
};
export default { get };

View File

@@ -1,216 +1,46 @@
import { Context, Hono } from 'hono'
import { Hono } from 'hono'
import i18n from '../i18n';
import { getBooleanValue, getJsonSetting, checkCfTurnstile, getStringValue, getSplitStringListValue, isAddressCountLimitReached } from '../utils';
import { newAddress, handleMailListQuery, deleteAddressWithData, getAddressPrefix, getAllowDomains, updateAddressUpdatedAt, generateRandomName } from '../common'
import { CONSTANTS } from '../constants'
import { resolveRawEmailRow } from '../gzip'
import parsed_mail_api from './parsed_mail_api';
import mails_crud from './mails_crud';
import new_address from './new_address';
import auto_reply from './auto_reply'
import webhook_settings from './webhook_settings';
import s3_attachment from './s3_attachment';
import address_auth from './address_auth';
import { getSendBalanceState } from './send_balance';
export const api = new Hono<HonoCustomType>()
// auto reply
api.get('/api/auto_reply', auto_reply.getAutoReply)
api.post('/api/auto_reply', auto_reply.saveAutoReply)
// webhook
api.get('/api/webhook/settings', webhook_settings.getWebhookSettings)
api.post('/api/webhook/settings', webhook_settings.saveWebhookSettings)
api.post('/api/webhook/test', webhook_settings.testWebhookSettings)
// attachment (S3)
api.get('/api/attachment/list', s3_attachment.list)
api.post('/api/attachment/delete', s3_attachment.deleteKey)
api.post('/api/attachment/put_url', s3_attachment.getSignedPutUrl)
api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl)
api.get('/api/mails', async (c) => {
const { address } = c.get("jwtPayload")
if (!address) {
return c.json({ "error": "No address" }, 400)
}
const { limit, offset } = c.req.query();
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
return await handleMailListQuery(c,
`SELECT * FROM raw_mails where address = ?`,
`SELECT count(*) as count FROM raw_mails where address = ?`,
[address], limit, offset
);
})
// mail crud
api.get('/api/mails', mails_crud.listMails)
api.get('/api/mail/:mail_id', mails_crud.getMail)
api.delete('/api/mails/:id', mails_crud.deleteMail)
api.get('/api/mail/:mail_id', async (c) => {
const { address } = c.get("jwtPayload")
const { mail_id } = c.req.param();
const result = await c.env.DB.prepare(
`SELECT * FROM raw_mails where id = ? and address = ?`
).bind(mail_id, address).first();
if (!result) return c.json(null);
return c.json(await resolveRawEmailRow(result));
})
// parsed mail (server-side parsed subject/text/html/attachments)
api.get('/api/parsed_mails', parsed_mail_api.listParsedMails)
api.get('/api/parsed_mail/:mail_id', parsed_mail_api.getParsedMail)
api.delete('/api/mails/:id', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { id } = c.req.param();
// TODO: add toLowerCase() to handle old data
const { success } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address = ? and id = ? `
).bind(address.toLowerCase(), id).run();
return c.json({
success: success
})
})
api.get('/api/settings', async (c) => {
const { address, address_id } = c.get("jwtPayload")
const user_role = c.get("userRolePayload")
const msgs = i18n.getMessagesbyContext(c);
if (address_id && address_id > 0) {
try {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where id = ? `
).bind(address_id).first("id");
if (!db_address_id) {
return c.text(msgs.InvalidAddressMsg, 400)
}
} catch (error) {
return c.text(msgs.InvalidAddressMsg, 400)
}
}
// check address id
try {
if (!address_id) {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ? `
).bind(address).first("id");
if (!db_address_id) {
return c.text(msgs.InvalidAddressMsg, 400)
}
}
} catch (error) {
return c.text(msgs.InvalidAddressMsg, 400)
}
updateAddressUpdatedAt(c, address);
const { balance } = await getSendBalanceState(c, address);
return c.json({
address: address,
send_balance: balance || 0,
});
})
api.post('/api/new_address', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const userPayload = c.get("userPayload");
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL)
&& !userPayload
) {
return c.text(msgs.NewAddressAnonymousDisabledMsg, 403)
}
if (!getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL)) {
return c.text(msgs.NewAddressDisabledMsg, 403)
}
// 如果启用了禁止匿名创建,且用户已登录,检查地址数量限制
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL) && userPayload) {
const userRole = c.get("userRolePayload");
if (await isAddressCountLimitReached(c, userPayload.user_id, userRole)) {
return c.text(msgs.MaxAddressCountReachedMsg, 400)
}
}
// eslint-disable-next-line prefer-const
let { name, domain, cf_token, enableRandomSubdomain } = await c.req.json();
// check cf turnstile
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 400)
}
// Check if custom email names are disabled from environment variable
const disableCustomAddressName = getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME);
// if no name or custom names are disabled, generate random name
if (!name || disableCustomAddressName) {
// Generate random name with context-based length configuration
name = generateRandomName(c);
}
// check name block list
try {
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
const blockList = (value || []) as string[];
if (blockList.some((item) => name.includes(item))) {
return c.text(`Name[${name}]is blocked`, 400)
}
} catch (error) {
console.error(error);
}
try {
const addressPrefix = await getAddressPrefix(c);
// Get client IP for source tracking
const sourceMeta = c.req.header('CF-Connecting-IP')
|| c.req.header('X-Forwarded-For')?.split(',')[0]?.trim()
|| c.req.header('X-Real-IP')
|| 'web:unknown';
const res = await newAddress(c, {
name, domain,
enablePrefix: true,
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
checkLengthByConfig: true,
addressPrefix,
sourceMeta
});
return c.json(res);
} catch (e) {
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
}
})
api.delete('/api/delete_address', async (c) => {
const { address, address_id } = c.get("jwtPayload")
const success = await deleteAddressWithData(c, address, address_id);
return c.json({
success: success
})
})
api.delete('/api/clear_inbox', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { success } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text(msgs.FailedClearInboxMsg, 500)
}
return c.json({
success: success
})
})
api.delete('/api/clear_sent_items', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { success } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text(msgs.FailedClearSentItemsMsg, 500)
}
return c.json({
success: success
})
})
// address settings / lifecycle
api.get('/api/settings', mails_crud.getSettings)
api.post('/api/new_address', new_address.createNewAddress)
api.delete('/api/delete_address', mails_crud.deleteAddress)
api.delete('/api/clear_inbox', mails_crud.clearInbox)
api.delete('/api/clear_sent_items', mails_crud.clearSentItems)
// address auth
api.post('/api/address_change_password', address_auth.changePassword)
api.post('/api/address_login', address_auth.login)

View File

@@ -0,0 +1,120 @@
import { Context } from 'hono'
import i18n from '../i18n';
import { getBooleanValue } from '../utils';
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
import { resolveRawEmailRow } from '../gzip'
import { getSendBalanceState } from './send_balance';
const listMails = async (c: Context<HonoCustomType>) => {
const { address } = c.get("jwtPayload")
if (!address) {
return c.json({ "error": "No address" }, 400)
}
const { limit, offset } = c.req.query();
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
return await handleMailListQuery(c,
`SELECT * FROM raw_mails where address = ?`,
`SELECT count(*) as count FROM raw_mails where address = ?`,
[address], limit, offset
);
};
const getMail = async (c: Context<HonoCustomType>) => {
const { address } = c.get("jwtPayload")
const { mail_id } = c.req.param();
const result = await c.env.DB.prepare(
`SELECT * FROM raw_mails where id = ? and address = ?`
).bind(mail_id, address).first();
if (!result) return c.json(null);
return c.json(await resolveRawEmailRow(result));
};
const deleteMail = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { id } = c.req.param();
// TODO: add toLowerCase() to handle old data
const { success } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address = ? and id = ? `
).bind(address.toLowerCase(), id).run();
return c.json({ success });
};
const getSettings = async (c: Context<HonoCustomType>) => {
const { address, address_id } = c.get("jwtPayload")
const msgs = i18n.getMessagesbyContext(c);
if (address_id && address_id > 0) {
try {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where id = ? `
).bind(address_id).first("id");
if (!db_address_id) {
return c.text(msgs.InvalidAddressMsg, 400)
}
} catch (error) {
return c.text(msgs.InvalidAddressMsg, 400)
}
}
try {
if (!address_id) {
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ? `
).bind(address).first("id");
if (!db_address_id) {
return c.text(msgs.InvalidAddressMsg, 400)
}
}
} catch (error) {
return c.text(msgs.InvalidAddressMsg, 400)
}
updateAddressUpdatedAt(c, address);
const { balance } = await getSendBalanceState(c, address);
return c.json({
address: address,
send_balance: balance || 0,
});
};
const deleteAddress = async (c: Context<HonoCustomType>) => {
const { address, address_id } = c.get("jwtPayload")
const success = await deleteAddressWithData(c, address, address_id);
return c.json({ success });
};
const clearInbox = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { success } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text(msgs.FailedClearInboxMsg, 500)
}
return c.json({ success });
};
const clearSentItems = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { success } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text(msgs.FailedClearSentItemsMsg, 500)
}
return c.json({ success });
};
export default { listMails, getMail, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };

View File

@@ -0,0 +1,74 @@
import { Context } from 'hono'
import i18n from '../i18n';
import { getBooleanValue, getJsonSetting, checkCfTurnstile, isAddressCountLimitReached } from '../utils';
import { newAddress, getAddressPrefix, generateRandomName } from '../common'
import { CONSTANTS } from '../constants'
const createNewAddress = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const userPayload = c.get("userPayload");
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL)
&& !userPayload
) {
return c.text(msgs.NewAddressAnonymousDisabledMsg, 403)
}
if (!getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL)) {
return c.text(msgs.NewAddressDisabledMsg, 403)
}
// 如果启用了禁止匿名创建,且用户已登录,检查地址数量限制
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL) && userPayload) {
const userRole = c.get("userRolePayload");
if (await isAddressCountLimitReached(c, userPayload.user_id, userRole)) {
return c.text(msgs.MaxAddressCountReachedMsg, 400)
}
}
// eslint-disable-next-line prefer-const
let { name, domain, cf_token, enableRandomSubdomain } = await c.req.json();
// check cf turnstile
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 400)
}
// Check if custom email names are disabled from environment variable
const disableCustomAddressName = getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME);
// if no name or custom names are disabled, generate random name
if (!name || disableCustomAddressName) {
name = generateRandomName(c);
}
// check name block list
try {
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
const blockList = (value || []) as string[];
if (blockList.some((item) => name.includes(item))) {
return c.text(`Name[${name}]is blocked`, 400)
}
} catch (error) {
console.error(error);
}
try {
const addressPrefix = await getAddressPrefix(c);
const sourceMeta = c.req.header('CF-Connecting-IP')
|| c.req.header('X-Forwarded-For')?.split(',')[0]?.trim()
|| c.req.header('X-Real-IP')
|| 'web:unknown';
const res = await newAddress(c, {
name, domain,
enablePrefix: true,
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
checkLengthByConfig: true,
addressPrefix,
sourceMeta
});
return c.json(res);
} catch (e) {
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
}
};
export default { createNewAddress };

View File

@@ -0,0 +1,52 @@
import { Context } from 'hono'
import { commonParseMail, handleMailListQuery, updateAddressUpdatedAt } from '../common'
import { resolveRawEmailRow } from '../gzip'
const toParsedMailRow = async (row: Record<string, unknown>): Promise<Record<string, unknown>> => {
const raw = typeof row.raw === 'string' ? row.raw : '';
const parsed = raw ? await commonParseMail({ rawEmail: raw }) : undefined;
const { raw: _raw, ...rest } = row;
return {
...rest,
sender: parsed?.sender ?? '',
subject: parsed?.subject ?? '',
text: parsed?.text ?? '',
html: parsed?.html ?? '',
attachments: (parsed?.attachments ?? []).map(a => ({
filename: a.filename,
mimeType: a.mimeType,
disposition: a.disposition,
size: a.content?.length ?? 0,
})),
};
};
const listParsedMails = async (c: Context<HonoCustomType>) => {
const { address } = c.get("jwtPayload");
if (!address) return c.json({ "error": "No address" }, 400);
const { limit, offset } = c.req.query();
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
const listRes = await handleMailListQuery(c,
`SELECT * FROM raw_mails where address = ?`,
`SELECT count(*) as count FROM raw_mails where address = ?`,
[address], limit, offset
);
if (listRes.status !== 200) return listRes;
const { results, count } = await listRes.json() as { results: Record<string, unknown>[], count: number };
const parsed = await Promise.all(results.map(toParsedMailRow));
return c.json({ results: parsed, count });
};
const getParsedMail = async (c: Context<HonoCustomType>) => {
const { address } = c.get("jwtPayload");
const { mail_id } = c.req.param();
const row = await c.env.DB.prepare(
`SELECT * FROM raw_mails where id = ? and address = ?`
).bind(mail_id, address).first();
if (!row) return c.json(null);
const resolved = await resolveRawEmailRow(row);
return c.json(await toParsedMailRow(resolved as Record<string, unknown>));
};
export default { listParsedMails, getParsedMail };