feat: WmailHub v1.0.0 —— Cloudflare Email Workers 邮件接收与转发平台(本地可用版本)

- Console:React 19 + antd 6.6.5 前端(登录 admin/admin)+ Hono 管理 API
  (Nodes/Domains/Mailboxes/Emails/Webhooks/Keys/OAuth/Admin)
- Node:Bun 转发节点(HMAC 签名、有限重试、心跳、结构化日志、优雅退出)
- Email Worker:Cloudflare email handler + 本地联调入口
- shared:HMAC 协议(docs/04)、wmh_ Key(32 位)、AES-GCM 加密、SSRF 防护
- 邮件管线:postal-mime 解析、附件、catch-all、限额、malformed 降级、TTL 1 天
- Webhook:签名投递、指数退避重试、SSRF 校验、投递日志
- API v1 + MCP(JSON-RPC,六工具,凭据 kind 隔离)
- Cloudflare OAuth:真实 + 本地 sim 模式,自动安装管线(幂等,无需手工命令)
- 测试:bun test 41 例(协议安全/管线/TTL/Webhook/SSRF/MCP/OAuth/鉴权)+ Playwright UI 冒烟
- LOGO/favicon 由 codex CLI 生成
This commit is contained in:
BigCode
2026-09-30 03:34:37 +08:00
commit d4c1334143
87 changed files with 9268 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# 依赖与构建产物
node_modules/
console/public/
worker/dist/
# 本地运行时数据(含 secret,严禁入库)
console/data/
node/config.json
*.local.sqlite
*.local.sqlite-shm
*.local.sqlite-wal
# 环境
.env
.env.*
!.env.example
# 日志与临时
*.log
nohup.out
/tmp/
# 编辑器
.DS_Store
.idea/
+102
View File
@@ -0,0 +1,102 @@
# WmailHub
基于 **Cloudflare Email Workers** 的轻量邮件接收与转发平台。
```text
Internet → Cloudflare Email Routing → Email Worker → Node → Console → D1/R2
├→ Webhook
├→ API v1
└→ MCP
```
- **Console**(控制平面)= 前端(React + antd v6)+ 管理 API,部署在 Cloudflare Workers,负责 Cloudflare OAuth、Node/Domain/Mailbox/Email/Webhook 管理、数据存储与 TTL 清理。
- **Node**(转发平面)= 纯后端转发节点(Bun),收到什么转发什么,不做业务判断。
- **Email Worker** = Cloudflare 邮件入口,只把邮件事件安全转发给 Node。
## 本地开发(无需 Cloudflare 账号)
```sh
bun install
bun run dev # 一键启动:console + node + email worker + 自动 OAuth(sim)安装
bun run dev -- --reset # 清空本地数据重新安装
bun test # 测试套件(协议安全/管线/TTL/Webhook/MCP/OAuth)
bun tests/ui-smoke.ts # UI 冒烟(需本机 Playwright chromium)
```
启动后:
| 服务 | 地址 |
| --- | --- |
| Console(前端 + API) | http://127.0.0.1:18787 |
| Node | http://127.0.0.1:18788/__health |
| Email Worker(本地联调) | http://127.0.0.1:18789/__health |
**登录**:默认 `admin / admin`。
**发测试邮件**(走完整链路 Worker→Node→Console):
```sh
SECRET=$(python3 -c "import json;print(json.load(open('node/config.json'))['ingressSecret'])")
curl -X POST http://127.0.0.1:18789/__email_test \
-H "X-WmailHub-Ingress-Secret: $SECRET" \
-H "X-WmailHub-Test-From: alice@example.org" \
-H "X-WmailHub-Test-To: hello@example.test" \
--data-binary @tests/fixtures/test-mail.eml
```
`bun run dev` 首次运行会自动:创建本地 Node → enrollment 换取身份(config.json,0600)→ sim 模式 Cloudflare OAuth 自动安装(Email Worker / Email Routing / Domain `example.test` / catch-all Mailbox)→ Node 心跳上线。全程无需复制任何安装命令。
## 功能
- **用户登录**:用户名密码(PBKDF2),默认 admin/admin,HMAC 签名会话 cookie。
- **Node 管理**:Add Node → Cloudflare OAuth(本地 sim / 真实 OAuth)→ 自动安装,步骤可视化,凭据轮换。
- **邮件管线**:MIME 解析(postal-mime)、附件、catch-all/精确匹配、大小限额、malformed 降级入库。
- **TTL**:邮件默认保留 1 天(`expires_at`),Cron 定期清理 R2 + DB + orphan,幂等。
- **Webhook**:`email.received` 事件推送,HMAC 签名头(`X-WmailHub-Signature`),指数退避重试,SSRF 防护。
- **API v1**:`Authorization: Bearer wmh_...` 访问 `/api/v1/*`。
- **MCP**:`/api/v1/mcp`(JSON-RPC over HTTP),工具:`list_mailboxes` / `list_emails` / `get_email` / `create_mailbox` / `delete_mailbox` / `list_webhooks`。
- **Key 管理**:API Key 与 MCP Key 在后端设置,`wmh_` 前缀 + 随机串,总长 32 位;库中只存 SHA-256,明文仅创建时展示一次。
## Node 协议(docs/04)
```text
body_hash = SHA256(raw_body)
canonical = METHOD \n PATH \n TIMESTAMP \n NONCE \n body_hash
signature = HMAC-SHA256(node_credential_secret, canonical)
```
请求头:`Authorization: WmailHub-Node <node_id>`、`X-WmailHub-Timestamp`(±5 分钟)、`X-WmailHub-Nonce`(防重放)、`X-WmailHub-Body-SHA256`、`X-WmailHub-Signature`、`X-WmailHub-Protocol: 1`。
## 目录结构
```text
WmailHub/
console/ Console(Workers 入口 src/worker.ts + 本地运行时 src/local/main.ts + 前端 frontend/)
node/ Node 转发节点(Bun)
worker/ Email Worker(Cloudflare 部署入口 + 本地联调 local.ts)
shared/ 共享协议:HMAC 签名 / Key 生成 / 加密 / SSRF 防护 / Email Worker 脚本模板
migrations/ D1 schema
tests/ bun test 测试 + UI 冒烟 + fixtures
scripts/ dev 启动 / 前端构建
docs/ 设计文档(00~10)
```
## 生产部署(Cloudflare Workers)
1. `bunx wrangler d1 create wmailhub` → 将 `database_id` 填入 `console/wrangler.jsonc`
2. `bunx wrangler r2 bucket create wmailhub`
3. `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql`
4. `cd console && bunx wrangler deploy`(前端先 `bun run build`)
5. `bunx wrangler secret put SESSION_SECRET --config console/wrangler.jsonc`(≥32 位随机串)
6. Cloudflare Dashboard 创建 OAuth App(client id/secret),配置 `CF_OAUTH_CLIENT_ID/SECRET` vars 与回调 `<PUBLIC_BASE_URL>/api/oauth/cloudflare/callback`;未配置时自动进入 sim 模式(仅供本地)
7. Node 部署在任意能被 Email Worker 访问的服务器:`WMAILHUB_ENROLLMENT_TOKEN=... bun node/src/main.ts`(token 在 Console「添加节点」后展示一次)
Cron(`*/1 * * * *`)驱动 TTL 清理与 Webhook 重试;Email Worker 由 Console provisioning 自动上传,无需手工部署。
## 安全设计(docs/08)
- OAuth token、Node credential、ingress secret 全部 AES-256-GCM 加密存储;凭据撤销即时生效。
- Node→Console 强制 HTTPS + HMAC 签名 + timestamp 窗口 + nonce 防重放 + body hash 防篡改。
- Worker 只持有 Node ingress secret,不持有 Console 凭据。
- Webhook endpoint 防 SSRF(私网/loopback/metadata/非法 scheme;本地联调需显式 `WMAILHUB_ALLOW_PRIVATE_WEBHOOKS=1`)。
- 会话 cookie `SameSite=Strict` + 跨域 Origin 校验;日志不落 secret 与邮件正文。
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="WmailHub icon">
<rect x="2" y="2" width="60" height="60" rx="15" fill="#2f54eb"/>
<path d="M16 39a9 9 0 0 1-1-18 17 17 0 0 1 32-1 10 10 0 0 1 1 19H16Z" fill="#ccdcff"/>
<rect x="12" y="28" width="40" height="27" rx="5" fill="#fff"/>
<path d="m14 32 16 12a3.5 3.5 0 0 0 4 0l16-12" fill="none" stroke="#2f54eb" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m14 52 12-10m24 10L38 42" fill="none" stroke="#9bb4ff" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 606 B

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="WmailHub logo">
<defs>
<linearGradient id="background" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#5578ff"/>
<stop offset="55%" style="stop-color:#2f54eb"/>
<stop offset="100%" style="stop-color:#1938bd"/>
</linearGradient>
</defs>
<rect x="24" y="24" width="464" height="464" rx="108" fill="#2f54eb"/>
<rect x="24" y="24" width="464" height="464" rx="108" fill="url(#background)" opacity=".2"/>
<path d="M132 310a68 68 0 0 1-4-136 132 132 0 0 1 248-5 71 71 0 0 1 8 141H132Z" fill="#dbe7ff"/>
<rect x="110" y="222" width="292" height="188" rx="30" fill="#fff"/>
<path d="m124 246 119 89a22 22 0 0 0 26 0l119-89" fill="none" stroke="#2f54eb" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m122 391 88-74m180 74-88-74" fill="none" stroke="#a5bbff" stroke-width="12" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1007 B

+192
View File
@@ -0,0 +1,192 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "wmailhub",
"dependencies": {
"@ant-design/icons": "^6.3.4",
"antd": "^6.6.5",
"hono": "^4.9.0",
"postal-mime": "^2.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.0",
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"bun-types": "^1.2.0",
"playwright-core": "^1.63.0",
"typescript": "^5.9.0",
},
},
},
"packages": {
"@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="],
"@ant-design/cssinjs": ["@ant-design/cssinjs@2.1.2", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "csstype": "^3.1.3", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ=="],
"@ant-design/cssinjs-utils": ["@ant-design/cssinjs-utils@2.1.2", "", { "dependencies": { "@ant-design/cssinjs": "^2.1.2", "@babel/runtime": "^7.23.2", "@rc-component/util": "^1.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA=="],
"@ant-design/fast-color": ["@ant-design/fast-color@3.0.1", "", {}, "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw=="],
"@ant-design/icons": ["@ant-design/icons@6.3.4", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/icons-svg": "^4.6.0", "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-kDoUlppczVyCUTFthF6cHmPzMBYqNZQhu9j6EyIX/YIdM9j9CZNZoJUjzVw7/1v1VO8WwVMmPp15r/uwIAS0AQ=="],
"@ant-design/icons-svg": ["@ant-design/icons-svg@4.6.0", "", {}, "sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ=="],
"@ant-design/react-slick": ["@ant-design/react-slick@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "clsx": "^2.1.1", "json2mq": "^0.2.0", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
"@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="],
"@rc-component/async-validator": ["@rc-component/async-validator@6.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA=="],
"@rc-component/cascader": ["@rc-component/cascader@1.22.0", "", { "dependencies": { "@rc-component/select": "~1.10.0", "@rc-component/tree": "~1.4.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw=="],
"@rc-component/checkbox": ["@rc-component/checkbox@2.0.0", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ=="],
"@rc-component/collapse": ["@rc-component/collapse@1.2.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw=="],
"@rc-component/color-picker": ["@rc-component/color-picker@3.1.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.1", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg=="],
"@rc-component/context": ["@rc-component/context@2.0.2", "", { "dependencies": { "@rc-component/util": "^1.11.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA=="],
"@rc-component/dialog": ["@rc-component/dialog@1.10.0", "", { "dependencies": { "@rc-component/motion": "^1.3.3", "@rc-component/portal": "^2.1.0", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ=="],
"@rc-component/drawer": ["@rc-component/drawer@1.4.2", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.1.3", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q=="],
"@rc-component/dropdown": ["@rc-component/dropdown@1.0.3", "", { "dependencies": { "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" } }, "sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g=="],
"@rc-component/form": ["@rc-component/form@1.8.6", "", { "dependencies": { "@rc-component/async-validator": "^6.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-1EXVsSKPZC6kGrIqxVck7kAWM6T65b6et/n5wIcyiaIa41VrDshD0nVLHoBDEIZr2ATQsOP6icb4XQkNFMLBAw=="],
"@rc-component/image": ["@rc-component/image@1.10.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/portal": "^2.1.2", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw=="],
"@rc-component/input": ["@rc-component/input@1.3.1", "", { "dependencies": { "@rc-component/resize-observer": "^1.1.1", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig=="],
"@rc-component/input-number": ["@rc-component/input-number@1.6.2", "", { "dependencies": { "@rc-component/mini-decimal": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w=="],
"@rc-component/listy": ["@rc-component/listy@1.2.3", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.3.1", "@rc-component/virtual-list": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ=="],
"@rc-component/mentions": ["@rc-component/mentions@1.12.0", "", { "dependencies": { "@rc-component/input": "~1.3.1", "@rc-component/menu": "~1.5.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-v5MBx8zDcqCT+uybJgKpcx3Fgrvj0m3u+A/15RlSBaMx12lLoJZTTYggC3pbYYfohcKujWWUyBz6O6Gp2vGiNA=="],
"@rc-component/menu": ["@rc-component/menu@1.5.0", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-RjvzPsZkwVEg3xu+/jDDqDeUCBJAnWylONFlGeQgg5enlNAnlpybqhaMGE/UPEIvr6hL90PGhjFBguJqj0Qb2Q=="],
"@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.4", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w=="],
"@rc-component/motion": ["@rc-component/motion@1.3.3", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A=="],
"@rc-component/mutate-observer": ["@rc-component/mutate-observer@2.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w=="],
"@rc-component/notification": ["@rc-component/notification@2.0.9", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.13.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-jQT5IVSWSHPTNFXQM2YQONPptjiKXZa4A4Kzwh2xkh3MkL31gRzyjb98N3QHBzLxsyGGJK1aNpn9j9aSO/O0dg=="],
"@rc-component/overflow": ["@rc-component/overflow@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA=="],
"@rc-component/pagination": ["@rc-component/pagination@1.4.0", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw=="],
"@rc-component/picker": ["@rc-component/picker@1.12.2", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/trigger": "^3.6.15", "@rc-component/util": "^1.13.0", "clsx": "^2.1.1" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "dayjs", "luxon", "moment"] }, "sha512-ZB1A+tzTQkgtR4flPrAMvxIAOelIIOOOsp22fU2Nad6PbZcJacnyGJUJqUH7ZS9mwxeUwurf1pLMKXrVsK1tlA=="],
"@rc-component/portal": ["@rc-component/portal@2.2.1", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA=="],
"@rc-component/progress": ["@rc-component/progress@1.0.3", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Q1nPLIvKr95el/q76yaBh5nv1hqCsWaHQb8U7vXEDMefEhHgM7n3CPoN+o4LHnTyANQHmMFgoS/naAT4Bhxuaw=="],
"@rc-component/qrcode": ["@rc-component/qrcode@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.7" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw=="],
"@rc-component/rate": ["@rc-component/rate@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw=="],
"@rc-component/resize-observer": ["@rc-component/resize-observer@1.1.2", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q=="],
"@rc-component/segmented": ["@rc-component/segmented@1.4.0", "", { "dependencies": { "@babel/runtime": "^8.0.0", "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-NZlpKGSplOYlmF5ukUF1fCLr0ns5WfjNoqMlMkf4AHP0iu90wyiP7hn2qZKRHEuRlG4CvEEz/lF9qRiYnLTofQ=="],
"@rc-component/select": ["@rc-component/select@1.10.1", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw=="],
"@rc-component/slider": ["@rc-component/slider@1.1.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig=="],
"@rc-component/steps": ["@rc-component/steps@1.2.3", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-/b9gHcvDGjTDWJZW7+1kKFvzs0jrEeQ8mFvvIgX+h7xNT0FsOYODJYKjyJR4fPbvMBCfCOEqaBK66HBxov5BQA=="],
"@rc-component/switch": ["@rc-component/switch@1.0.3", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw=="],
"@rc-component/table": ["@rc-component/table@1.11.1", "", { "dependencies": { "@rc-component/context": "^2.0.1", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw=="],
"@rc-component/tabs": ["@rc-component/tabs@1.13.0", "", { "dependencies": { "@rc-component/dropdown": "~1.0.0", "@rc-component/menu": "~1.5.0", "@rc-component/motion": "^1.1.3", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3FXr+9ZII8tFLWuDWrgjRaxZd2mazkKJAGRFEfVmp4r1qnBglOlxQ78l54Td1aj6/iFaaEReKSAF2HToztU5uw=="],
"@rc-component/tooltip": ["@rc-component/tooltip@1.5.2", "", { "dependencies": { "@rc-component/trigger": "^3.10.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-VLq4BclU3LgRe46g2hTr2bdsM8tFYs7dVwpuD88rPNNg4lCGe8mBSS4FiRzYzS+ffnZ46y4SI8KYKo9K7qBnhw=="],
"@rc-component/tour": ["@rc-component/tour@2.4.0", "", { "dependencies": { "@rc-component/portal": "^2.2.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.7.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg=="],
"@rc-component/tree": ["@rc-component/tree@1.4.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q=="],
"@rc-component/tree-select": ["@rc-component/tree-select@1.16.1", "", { "dependencies": { "@rc-component/select": "~1.10.0", "@rc-component/tree": "~1.4.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg=="],
"@rc-component/trigger": ["@rc-component/trigger@3.10.1", "", { "dependencies": { "@rc-component/motion": "^1.3.3", "@rc-component/portal": "^2.2.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw=="],
"@rc-component/upload": ["@rc-component/upload@1.1.1", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA=="],
"@rc-component/util": ["@rc-component/util@1.13.0", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^19.2.7" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-IXf2zBfZrbQGT+G8LxnoICDBGnMHIPIiq5HCRn3EDfKUx28x1rnbZBj46ULJf4sM8ImTG1SepqfNBdxZziqQ4w=="],
"@rc-component/virtual-list": ["@rc-component/virtual-list@1.5.2", "", { "dependencies": { "@babel/runtime": "^8.0.0", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-igfe7NP9cOmDAhLy5z+Juz8fhjOMzMbF7TRoR/yqXIsbU6XZm4TU7ogcm5O553P0rLF6n0j5F6J+n15Izb1tqg=="],
"@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"antd": ["antd@6.6.5", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.3.4", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.29.2", "@rc-component/cascader": "~1.22.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.10.0", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.3", "@rc-component/form": "~1.8.6", "@rc-component/image": "~1.10.0", "@rc-component/input": "~1.3.1", "@rc-component/input-number": "~1.6.2", "@rc-component/listy": "~1.2.3", "@rc-component/mentions": "~1.12.0", "@rc-component/menu": "~1.5.0", "@rc-component/motion": "^1.3.3", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~2.0.8", "@rc-component/pagination": "~1.4.0", "@rc-component/picker": "~1.12.2", "@rc-component/progress": "~1.0.3", "@rc-component/qrcode": "~2.0.0", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.4.0", "@rc-component/select": "~1.10.1", "@rc-component/slider": "~1.1.1", "@rc-component/steps": "~1.2.3", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.11.1", "@rc-component/tabs": "~1.13.0", "@rc-component/tooltip": "~1.5.2", "@rc-component/tour": "~2.4.0", "@rc-component/tree": "~1.4.0", "@rc-component/tree-select": "~1.16.1", "@rc-component/trigger": "^3.10.1", "@rc-component/upload": "~1.1.1", "@rc-component/util": "^1.13.0", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-biJB/aORNU2lvZZCX783S/g8atfE8akrEKRjRrsXVttoMtJbSHLf/Z29niv0blzt6H3BgKvhkQgHoUn5MgCx7w=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
"hono": ["hono@4.13.11", "", {}, "sha512-/SMX/RQNJn7oNmFwH6DtwDqcrzZU2otm1FD6OJe2cWF6cfy/F8hq0XVBv7xW3FTJshRQwuBnXHDq2kNsdZnshg=="],
"is-mobile": ["is-mobile@5.0.0", "", {}, "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ=="],
"json2mq": ["json2mq@0.2.0", "", { "dependencies": { "string-convert": "^0.2.0" } }, "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA=="],
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
"postal-mime": ["postal-mime@2.7.6", "", {}, "sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
"react-is": ["react-is@19.3.0", "", {}, "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA=="],
"react-router": ["react-router@7.18.4", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ=="],
"react-router-dom": ["react-router-dom@7.18.4", "", { "dependencies": { "react-router": "7.18.4" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw=="],
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="],
"stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
"throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"@rc-component/segmented/@babel/runtime": ["@babel/runtime@8.0.5", "", {}, "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg=="],
"@rc-component/virtual-list/@babel/runtime": ["@babel/runtime@8.0.5", "", {}, "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg=="],
}
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/assets/main.css" />
<title>WmailHub</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/assets/main.js"></script>
</body>
</html>
+121
View File
@@ -0,0 +1,121 @@
/** 应用框架:100vh 布局(侧栏 + 顶栏大标题 + 内容区滚动),会话守卫 */
import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { Avatar, Dropdown, Layout, Menu } from "antd";
import {
DashboardOutlined,
MailOutlined,
CloudServerOutlined,
GlobalOutlined,
BellOutlined,
KeyOutlined,
UserOutlined,
LogoutOutlined,
} from "@ant-design/icons";
import { health, logout, me } from "./api.ts";
const { Sider, Header, Content } = Layout;
/** # 成员变量: 侧栏菜单项 **/
const MENU_ITEMS = [
{ key: "/", icon: <DashboardOutlined />, label: "概览" },
{ key: "/nodes", icon: <CloudServerOutlined />, label: "节点 Nodes" },
{ key: "/domains", icon: <GlobalOutlined />, label: "域名 Domains" },
{ key: "/mailboxes", icon: <MailOutlined />, label: "邮箱 Mailboxes" },
{ key: "/emails", icon: <MailOutlined />, label: "邮件 Emails" },
{ key: "/webhooks", icon: <BellOutlined />, label: "Webhooks" },
{ key: "/keys", icon: <KeyOutlined />, label: "API / MCP Keys" },
];
/** # 成员变量: 路由 -> 页面标题/副标题 **/
const PAGE_META: Record<string, { title: string; subtitle: string }> = {
"/": { title: "概览", subtitle: "节点、邮件与投递总览" },
"/nodes": { title: "节点管理", subtitle: "通过 Cloudflare OAuth 自动安装的转发节点" },
"/domains": { title: "域名管理", subtitle: "接入 Email Routing 的收件域名" },
"/mailboxes": { title: "邮箱管理", subtitle: "收件地址与保留策略" },
"/emails": { title: "邮件", subtitle: "已接收邮件(默认保留 1 天)" },
"/webhooks": { title: "Webhooks", subtitle: "邮件事件推送与投递日志" },
"/keys": { title: "API / MCP Keys", subtitle: "wmh_ 前缀凭据,仅创建时可见" },
};
/** # 函数
* 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 版本号(package.json 来源)
*/
export function App() {
const navigate = useNavigate();
const location = useLocation();
const [ready, setReady] = useState(false);
const [username, setUsername] = useState("");
const [version, setVersion] = useState("");
useEffect(() => {
void (async () => {
try {
const [healthInfo, meInfo] = await Promise.all([health(), me()]);
setVersion(healthInfo.version);
setUsername(meInfo.user.username);
setReady(true);
} catch {
navigate("/login", { replace: true });
}
})();
}, [navigate]);
if (!ready) return null;
const meta = PAGE_META[location.pathname] ?? { title: "WmailHub", subtitle: "" };
return (
<Layout className="app-layout">
<Sider width={228} className="app-sider" theme="light">
<div className="brand">
<img src="/logo.svg" alt="WmailHub" />
<span className="name">WmailHub</span>
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={MENU_ITEMS}
onClick={({ key }) => navigate(key)}
style={{ borderInlineEnd: "none" }}
/>
</Sider>
<Layout>
<Header className="app-header">
<div>
<div className="title">{meta.title}</div>
<div className="subtitle">{meta.subtitle}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<span className="muted mono">v{version}</span>
{/* Dropdown 子元素必须是 DOM 元素(antd v6 坑),所以外包 div */}
<div>
<Dropdown
menu={{
items: [
{
key: "logout",
icon: <LogoutOutlined />,
label: "退出登录",
onClick: async () => {
await logout().catch(() => undefined);
window.location.href = "/login";
},
},
],
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
<Avatar size={30} icon={<UserOutlined />} />
<span>{username}</span>
</div>
</Dropdown>
</div>
</div>
</Header>
<Content className="app-content">
<Outlet />
</Content>
</Layout>
</Layout>
);
}
+291
View File
@@ -0,0 +1,291 @@
/** Console API 客户端:统一 JSON 请求、错误处理、401 跳登录 */
/** # 成员变量: 未登录回调(由应用注册,跳转登录页) **/
let onUnauthorized: (() => void) | null = null;
/** # 函数
* 注册 401 处理器
*/
export function setUnauthorizedHandler(fn: () => void): void {
onUnauthorized = fn;
}
/** # 函数
* 通用请求封装
* 参数: method/url/body(JSON)/query
* 返回值: 解析后的 JSON;非 2xx 抛 ApiError
*/
export async function api<T = unknown>(
url: string,
method: string = "GET",
body?: unknown,
): Promise<T> {
const resp = await fetch(url, {
method,
credentials: "include",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (resp.status === 401) {
onUnauthorized?.();
throw new ApiError(401, "未登录或会话已过期");
}
const text = await resp.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
if (!resp.ok) {
const message =
(data as { error?: string } | null)?.error ?? `请求失败 (${resp.status})`;
throw new ApiError(resp.status, message, data);
}
return data as T;
}
/** # 成员变量: API 错误 **/
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public data?: unknown,
) {
super(message);
}
}
/** # 函数
* 分页查询串拼接
*/
export function qs(params: Record<string, string | number | undefined>): string {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") sp.set(k, String(v));
}
const s = sp.toString();
return s ? `?${s}` : "";
}
/* ---- 具体接口封装 ---- */
/** 登录 */
export const login = (username: string, password: string) =>
api<{ ok: boolean; user: { id: string; username: string } }>("/api/auth/login", "POST", { username, password });
/** 登出 */
export const logout = () => api("/api/auth/logout", "POST");
/** 当前用户 */
export const me = () => api<{ user: { id: string; username: string } }>("/api/auth/me");
/** 健康检查(含版本号) */
export const health = () => api<{ ok: boolean; name: string; version: string }>("/api/health");
/** Dashboard 统计 */
export const stats = () =>
api<{
nodes_online: number;
nodes_total: number;
domains: number;
mailboxes: number;
emails: number;
emails_expired: number;
webhooks: number;
deliveries_pending: number;
oauth_connections: number;
recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[];
}>("/api/admin/stats");
/** 手动触发 Cron */
export const runCron = () => api<{ ok: boolean; stats: Record<string, number | string[]> }>("/api/admin/cron/run", "POST");
/* Nodes */
export interface NodeRow {
id: string;
name: string;
cloudflare_account_id: string | null;
status: string;
version: string | null;
last_seen_at: string | null;
ingress_url: string;
created_at: string;
}
export const listNodes = () => api<{ items: NodeRow[]; total: number }>("/api/nodes");
export const createNode = (name: string, ingress_url: string) =>
api<{ ok: boolean; node_id: string; enrollment_token: string; ingress_secret: string; worker_name: string }>(
"/api/nodes",
"POST",
{ name, ingress_url },
);
export interface NodeDetail {
node: NodeRow & { enrollment_expires_at: string | null };
steps: Record<string, { status: string; error?: string }>;
oauth: { id: string; account_id: string | null; created_at: string } | null;
domains: { id: string; name: string; status: string; worker_id: string | null }[];
}
export const getNode = (id: string) => api<NodeDetail>(`/api/nodes/${id}`);
export const deleteNode = (id: string) => api(`/api/nodes/${id}`, "DELETE");
export const rotateNode = (id: string) =>
api<{ ok: boolean; enrollment_token: string }>(`/api/nodes/${id}/rotate`, "POST");
export const getNodeIngressSecret = (id: string) =>
api<{ ingress_secret: string }>(`/api/nodes/${id}/ingress-secret`);
/* OAuth */
export const oauthMode = () => api<{ mode: "cloudflare" | "sim" }>("/api/oauth/mode");
export const oauthStart = (nodeId: string) =>
api<{ url: string; state: string }>(`/api/oauth/cloudflare/start?node_id=${encodeURIComponent(nodeId)}`);
export const oauthSimApprove = (state: string) =>
api<{ ok: boolean; error?: string; node_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state });
/* Domains */
export interface DomainRow {
id: string;
name: string;
status: string;
node_id: string | null;
node_name: string | null;
worker_id: string | null;
mailbox_count: number;
created_at: string;
}
export const listDomains = () => api<{ items: DomainRow[]; total: number }>("/api/domains");
export const createDomain = (name: string) => api<{ ok: boolean; id: string }>("/api/domains", "POST", { name });
export const deleteDomain = (id: string) => api(`/api/domains/${id}`, "DELETE");
/* Mailboxes */
export interface MailboxRow {
id: string;
address: string;
status: string;
is_catch_all: number;
retention_seconds: number | null;
domain_id: string;
domain_name: string;
email_count: number;
created_at: string;
}
export const listMailboxes = (domainId?: string) =>
api<{ items: MailboxRow[]; total: number }>(`/api/mailboxes${qs({ domain_id: domainId })}`);
export const createMailbox = (body: { domain_id: string; address: string; retention_seconds?: number; catch_all?: boolean }) =>
api<{ ok: boolean; id: string; address: string }>("/api/mailboxes", "POST", body);
export const patchMailbox = (id: string, body: { status?: string; retention_seconds?: number | null }) =>
api(`/api/mailboxes/${id}`, "PATCH", body);
export const deleteMailbox = (id: string) => api(`/api/mailboxes/${id}`, "DELETE");
/* Emails */
export interface EmailRow {
id: string;
mailbox_id: string | null;
mailbox_address: string | null;
message_id: string | null;
from_address: string | null;
to_addresses: string;
subject: string | null;
size: number;
received_at: string;
expires_at: string;
attachment_count: number;
}
export const listEmails = (params: { mailbox_id?: string; q?: string; limit?: number; offset?: number }) =>
api<{ items: EmailRow[]; total: number }>(`/api/emails${qs(params)}`);
export interface EmailDetail {
email: EmailRow & { text_body: string | null; html_body: string | null; raw_storage_key: string; cc_addresses: string };
attachments: { id: string; filename: string; content_type: string; size: number; content_id: string | null }[];
}
export const getEmail = (id: string) => api<EmailDetail>(`/api/emails/${id}`);
export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
/* Webhooks */
export interface WebhookRow {
id: string;
name: string;
endpoint: string;
enabled: number;
delivery_count: number;
success_count: number;
created_at: string;
}
export const listWebhooks = () => api<{ items: WebhookRow[]; total: number }>("/api/webhooks");
export const createWebhook = (name: string, endpoint: string) =>
api<{ ok: boolean; id: string; secret: string }>("/api/webhooks", "POST", { name, endpoint });
export const patchWebhook = (id: string, body: { name?: string; endpoint?: string; enabled?: boolean }) =>
api(`/api/webhooks/${id}`, "PATCH", body);
export const rotateWebhookSecret = (id: string) =>
api<{ ok: boolean; secret: string }>(`/api/webhooks/${id}/rotate`, "POST");
export const deleteWebhook = (id: string) => api(`/api/webhooks/${id}`, "DELETE");
export const testWebhook = (id: string) =>
api<{ ok: boolean; status?: number; error?: string }>(`/api/webhooks/${id}/test`, "POST");
export interface DeliveryRow {
id: string;
webhook_id: string;
email_id: string;
status: string;
attempt_count: number;
next_retry_at: string | null;
last_error: string | null;
delivered_at: string | null;
created_at: string;
subject: string | null;
}
export const listDeliveries = (webhookId: string) =>
api<{ items: DeliveryRow[] }>(`/api/webhooks/${webhookId}/deliveries`);
export const redeliver = (deliveryId: string) =>
api(`/api/webhooks/deliveries/${deliveryId}/redeliver`, "POST");
/* API/MCP Keys */
export interface KeyRow {
id: string;
kind: "api" | "mcp";
name: string;
key_last4: string;
created_at: string;
revoked_at: string | null;
last_used_at: string | null;
}
export const listKeys = () => api<{ items: KeyRow[] }>("/api/keys");
export const createKey = (kind: "api" | "mcp", name?: string) =>
api<{ ok: boolean; id: string; kind: string; key: string }>("/api/keys", "POST", { kind, name });
export const revokeKey = (id: string) => api(`/api/keys/${id}/revoke`, "POST");
export const deleteKey = (id: string) => api(`/api/keys/${id}`, "DELETE");
+55
View File
@@ -0,0 +1,55 @@
/** 前端入口:antd v6 + react-router,路由守卫由 App 内 authGate 状态控制 */
import React from "react";
import { createRoot } from "react-dom/client";
import { ConfigProvider, App as AntdApp } from "antd";
import zhCN from "antd/locale/zh_CN";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { antdTheme } from "./theme.ts";
import "./styles.css";
import { App } from "./App.tsx";
import { LoginPage } from "./pages/Login.tsx";
import { OAuthSimPage } from "./pages/OAuthSim.tsx";
import { DashboardPage } from "./pages/Dashboard.tsx";
import { NodesPage } from "./pages/Nodes.tsx";
import { DomainsPage } from "./pages/Domains.tsx";
import { MailboxesPage } from "./pages/Mailboxes.tsx";
import { EmailsPage } from "./pages/Emails.tsx";
import { WebhooksPage } from "./pages/Webhooks.tsx";
import { KeysPage } from "./pages/Keys.tsx";
import { setUnauthorizedHandler } from "./api.ts";
/** # 成员变量: 路由表 **/
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{ path: "/oauth/sim/authorize", element: <OAuthSimPage /> },
{
path: "/",
element: <App />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: "nodes", element: <NodesPage /> },
{ path: "domains", element: <DomainsPage /> },
{ path: "mailboxes", element: <MailboxesPage /> },
{ path: "emails", element: <EmailsPage /> },
{ path: "webhooks", element: <WebhooksPage /> },
{ path: "keys", element: <KeysPage /> },
],
},
]);
/** 未登录统一跳登录页 */
setUnauthorizedHandler(() => {
if (!location.pathname.startsWith("/login")) {
location.href = "/login";
}
});
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ConfigProvider locale={zhCN} theme={antdTheme}>
<AntdApp>
<RouterProvider router={router} />
</AntdApp>
</ConfigProvider>
</React.StrictMode>,
);
+115
View File
@@ -0,0 +1,115 @@
/** 概览页:统计卡 + 最近邮件 + 手动 Cron */
import { useCallback, useEffect, useState } from "react";
import { Button, Card, Col, Row, Statistic, Table, message } from "antd";
import { ReloadOutlined, ClearOutlined } from "@ant-design/icons";
import { health, runCron, stats } from "../api.ts";
/** # 成员变量: 最近邮件表格列 **/
const recentColumns = [
{ title: "主题", dataIndex: "subject", ellipsis: true },
{ title: "发件人", dataIndex: "from_address", width: 240, ellipsis: true },
{ title: "大小", dataIndex: "size", width: 100, render: (v: number) => fmtSize(v) },
{ title: "接收时间", dataIndex: "received_at", width: 190, render: (v: string) => new Date(v).toLocaleString() },
];
/** # 函数
* 字节数格式化
*/
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
/** # 函数
* 概览页组件
*/
export function DashboardPage() {
const [data, setData] = useState<Awaited<ReturnType<typeof stats>> | null>(null);
const [version, setVersion] = useState("");
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [s, h] = await Promise.all([stats(), health()]);
setData(s);
setVersion(h.version);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<div>
<Row gutter={[16, 16]}>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="节点在线" value={data?.nodes_online ?? 0} suffix={`/ ${data?.nodes_total ?? 0}`} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="域名" value={data?.domains ?? 0} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="邮箱" value={data?.mailboxes ?? 0} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="邮件(未过期)" value={data?.emails ?? 0} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="Webhook" value={data?.webhooks ?? 0} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="待投递" value={data?.deliveries_pending ?? 0} />
</Card>
</Col>
</Row>
<Card
title="最近邮件"
style={{ marginTop: 20 }}
extra={
<span style={{ display: "flex", gap: 8 }}>
<Button
icon={<ClearOutlined />}
onClick={async () => {
const res = await runCron();
message.success(`清理完成:删除 ${res.stats.expiredEmails} 封过期邮件`);
void load();
}}
>
手动触发 Cron(TTL 清理)
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
</span>
}
>
<Table
rowKey="id"
size="middle"
loading={loading}
columns={recentColumns}
dataSource={data?.recent_emails ?? []}
pagination={false}
/>
<div className="muted" style={{ marginTop: 12 }}>
服务版本 v{version} · 邮件默认保留 1 天,过期由 Cron 自动清理
</div>
</Card>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
/** 域名页 */
import { useCallback, useEffect, useState } from "react";
import { Button, Card, Form, Input, Modal, Space, Table, Tag, App } from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import { createDomain, deleteDomain, listDomains, type DomainRow } from "../api.ts";
/** # 函数
* 域名页组件
*/
export function DomainsPage() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<DomainRow[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listDomains()).items);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<div>
<div className="page-toolbar">
<span className="muted">OAuth 安装流程会自动创建域名;此处也可手工登记。</span>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)}>
添加域名
</Button>
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
columns={[
{ title: "域名", dataIndex: "name" },
{
title: "状态",
dataIndex: "status",
width: 100,
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>,
},
{ title: "节点", dataIndex: "node_name", width: 160, render: (v: string | null) => v ?? "-" },
{ title: "Worker", dataIndex: "worker_id", width: 220, render: (v: string | null) => v ?? "-" },
{ title: "邮箱数", dataIndex: "mailbox_count", width: 90 },
{
title: "操作",
width: 90,
render: (_: unknown, row: DomainRow) => (
<Button
size="small"
danger
onClick={() =>
modal.confirm({
title: `删除域名 ${row.name}?`,
onOk: async () => {
try {
await deleteDomain(row.id);
message.success("已删除");
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "删除失败");
}
},
})
}
>
删除
</Button>
),
},
]}
/>
</Card>
<Modal
title="添加域名"
open={open}
onCancel={() => setOpen(false)}
onOk={async () => {
const values = await form.validateFields();
try {
await createDomain(values.name);
message.success("已添加");
setOpen(false);
form.resetFields();
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "添加失败");
}
}}
okText="添加"
cancelText="取消"
destroyOnHidden
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="域名" rules={[{ required: true, message: "请输入域名" }]}>
<Input placeholder="example.com" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+228
View File
@@ -0,0 +1,228 @@
/** 邮件页:搜索、分页、详情(正文/HTML 预览/附件/raw 下载) */
import { useCallback, useEffect, useState } from "react";
import { Button, Card, Descriptions, Drawer, Input, Space, Table, Tag, Typography, App, Popconfirm, Modal } from "antd";
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { deleteEmail, getEmail, listEmails, type EmailDetail, type EmailRow } from "../api.ts";
/** # 函数
* 字节数格式化
*/
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
/** # 成员变量: 表格列(分页在组件内配置) **/
const columns = [
{
title: "主题",
dataIndex: "subject",
ellipsis: true,
render: (v: string | null) => v ?? <span className="muted">(无主题)</span>,
},
{ title: "发件人", dataIndex: "from_address", width: 220, ellipsis: true },
{ title: "邮箱", dataIndex: "mailbox_address", width: 200, ellipsis: true, render: (v: string | null) => v ?? "-" },
{
title: "附件",
dataIndex: "attachment_count",
width: 70,
render: (v: number) => (v > 0 ? <Tag color="blue">{v}</Tag> : "-"),
},
{ title: "大小", dataIndex: "size", width: 90, render: (v: number) => fmtSize(v) },
{
title: "接收时间",
dataIndex: "received_at",
width: 175,
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: "过期时间",
dataIndex: "expires_at",
width: 175,
render: (v: string) => <span className="muted">{new Date(v).toLocaleString()}</span>,
},
];
/** # 函数
* 邮件页组件
*/
export function EmailsPage() {
const { message } = App.useApp();
const [items, setItems] = useState<EmailRow[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [q, setQ] = useState("");
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<EmailDetail | null>(null);
const [htmlPreview, setHtmlPreview] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await listEmails({ q, limit: pageSize, offset: (page - 1) * pageSize });
setItems(res.items);
setTotal(res.total);
} finally {
setLoading(false);
}
}, [q, page, pageSize]);
useEffect(() => {
void load();
}, [load]);
return (
<div>
<div className="page-toolbar">
<Space>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索主题 / 发件人"
style={{ width: 280 }}
onChange={(e) => {
setQ(e.target.value);
setPage(1);
}}
/>
</Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
rowClassName={(row) => (row.expires_at <= new Date().toISOString() ? "muted" : "")}
onRow={(row) => ({
onClick: async () => {
setDetail(await getEmail(row.id));
setHtmlPreview(false);
},
style: { cursor: "pointer" },
})}
columns={columns}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
</Card>
<Drawer
title={detail?.email.subject ?? "邮件详情"}
size="large"
open={Boolean(detail)}
onClose={() => setDetail(null)}
extra={
detail && (
<Space>
<Button size="small" onClick={() => setHtmlPreview((v) => !v)} disabled={!detail.email.html_body}>
{htmlPreview ? "显示文本" : "HTML 预览"}
</Button>
<Button size="small" href={`/api/emails/${detail.email.id}/raw`} target="_blank">
下载 .eml
</Button>
<Popconfirm
title="删除这封邮件?"
onConfirm={async () => {
await deleteEmail(detail.email.id);
message.success("已删除");
setDetail(null);
void load();
}}
>
<Button size="small" danger>
删除
</Button>
</Popconfirm>
</Space>
)
}
>
{detail && (
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="发件人">{detail.email.from_address ?? "-"}</Descriptions.Item>
<Descriptions.Item label="收件人">
{safeArr(detail.email.to_addresses).join(", ") || "-"}
</Descriptions.Item>
<Descriptions.Item label="邮箱">{detail.email.mailbox_address ?? "(未匹配)"}</Descriptions.Item>
<Descriptions.Item label="Message-ID">
<span className="mono">{detail.email.message_id ?? "-"}</span>
</Descriptions.Item>
<Descriptions.Item label="接收 / 过期">
{new Date(detail.email.received_at).toLocaleString()} →{" "}
<span className="muted">{new Date(detail.email.expires_at).toLocaleString()}</span>
</Descriptions.Item>
</Descriptions>
{detail.attachments.length > 0 && (
<div>
<Typography.Title level={5}>附件({detail.attachments.length})</Typography.Title>
<Space orientation="vertical">
{detail.attachments.map((att) => (
<Button
key={att.id}
size="small"
href={`/api/emails/${detail.email.id}/attachments/${att.id}`}
target="_blank"
>
{att.filename}({fmtSize(att.size)})
</Button>
))}
</Space>
</div>
)}
{htmlPreview && detail.email.html_body ? (
<Modal
open
title="HTML 预览(沙箱隔离)"
width={720}
footer={null}
onCancel={() => setHtmlPreview(false)}
getContainer={false}
>
<iframe
className="email-html-frame"
sandbox=""
title="email html preview"
srcDoc={detail.email.html_body}
/>
</Modal>
) : (
<Typography.Paragraph
style={{ whiteSpace: "pre-wrap", maxHeight: 420, overflow: "auto" }}
className="mono"
>
{detail.email.text_body ?? "(无文本正文)"}
</Typography.Paragraph>
)}
</Space>
)}
</Drawer>
</div>
);
}
/** # 函数
* JSON 数组字段安全解析
*/
function safeArr(s: string | null): string[] {
try {
const v = JSON.parse(s ?? "[]");
return Array.isArray(v) ? v : [];
} catch {
return [];
}
}
+158
View File
@@ -0,0 +1,158 @@
/** API / MCP Key 管理页(wmh_ 前缀,总长 32,仅创建时可见) */
import { useCallback, useEffect, useState } from "react";
import { Button, Card, Dropdown, Space, Table, Tag, Typography, App } from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import { createKey, deleteKey, listKeys, revokeKey, type KeyRow } from "../api.ts";
/** # 函数
* Keys 页组件
*/
export function KeysPage() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<KeyRow[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listKeys()).items);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<div>
<div className="page-toolbar">
<span className="muted">
API Key 调用 /api/v1/*;MCP Key 调用 /api/v1/mcp(JSON-RPC)。均以 Authorization: Bearer wmh_...
传递;服务端只存 SHA-256,明文仅创建时展示一次。Key 总长 32 位(wmh_ + 28 位随机)。
</span>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
{/* Dropdown 子元素必须是 DOM 元素(antd v6),外包 div */}
<div>
<Dropdown
menu={{
items: [
{ key: "api", label: "生成 API Key" },
{ key: "mcp", label: "生成 MCP Key" },
],
onClick: ({ key }) => void genKey(key as "api" | "mcp"),
}}
>
<Button type="primary" icon={<PlusOutlined />}>
生成 Key
</Button>
</Dropdown>
</div>
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
columns={[
{
title: "类型",
dataIndex: "kind",
width: 90,
render: (v: string) => <Tag color={v === "api" ? "blue" : "purple"}>{v.toUpperCase()}</Tag>,
},
{ title: "名称", dataIndex: "name" },
{
title: "Key 尾缀",
dataIndex: "key_last4",
width: 110,
render: (v: string) => (
<span className="mono">
wmh_****{v}
</span>
),
},
{
title: "创建时间",
dataIndex: "created_at",
width: 175,
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: "最近使用",
dataIndex: "last_used_at",
width: 175,
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
},
{
title: "状态",
dataIndex: "revoked_at",
width: 90,
render: (v: string | null) =>
v ? <Tag color="error">已撤销</Tag> : <Tag color="success">有效</Tag>,
},
{
title: "操作",
width: 160,
render: (_: unknown, row: KeyRow) => (
<Space>
{!row.revoked_at && (
<Button
size="small"
onClick={async () => {
await revokeKey(row.id);
message.success("已撤销");
void load();
}}
>
撤销
</Button>
)}
<Button
size="small"
danger
onClick={async () => {
await deleteKey(row.id);
message.success("已删除记录");
void load();
}}
>
删除
</Button>
</Space>
),
},
]}
/>
</Card>
</div>
);
/** 生成 key 并展示一次 */
async function genKey(kind: "api" | "mcp") {
try {
const res = await createKey(kind);
modal.success({
title: `${kind.toUpperCase()} Key 已创建(仅显示一次)`,
width: 560,
content: (
<div style={{ marginTop: 12 }}>
<Typography.Paragraph type="warning">
请立即保存,关闭后无法再次查看。Key 格式:wmh_ 前缀 + 28 位随机串,总长 32。
</Typography.Paragraph>
<Typography.Paragraph copyable className="mono" style={{ fontSize: 15 }}>
{res.key}
</Typography.Paragraph>
</div>
),
});
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "生成失败");
}
}
}
+57
View File
@@ -0,0 +1,57 @@
/** 登录页:用户名密码(默认 admin/admin) */
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Card, Form, Input, App } from "antd";
import { LockOutlined, UserOutlined } from "@ant-design/icons";
import { login } from "../api.ts";
/** # 函数
* 登录页组件
*/
export function LoginPage() {
const navigate = useNavigate();
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
return (
<div className="login-page">
<Card className="login-card">
<div className="login-brand">
<img src="/logo.svg" alt="WmailHub" />
<span className="name">WmailHub</span>
</div>
<div style={{ textAlign: "center", marginBottom: 20 }} className="muted">
Cloudflare Email Workers 邮件接收平台
</div>
<Form
layout="vertical"
onFinish={async (values) => {
setLoading(true);
try {
await login(values.username, values.password);
message.success("登录成功");
navigate("/", { replace: true });
} catch (err) {
message.error(err instanceof Error ? err.message : "登录失败");
} finally {
setLoading(false);
}
}}
>
<Form.Item name="username" rules={[{ required: true, message: "请输入用户名" }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" size="large" autoFocus />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: "请输入密码" }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" size="large" />
</Form.Item>
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
登录
</Button>
</Form>
<div style={{ marginTop: 14, textAlign: "center" }} className="muted">
默认账号 admin / admin,登录后请尽快修改
</div>
</Card>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
/** 邮箱页:创建/停用/删除/保留策略 */
import { useCallback, useEffect, useState } from "react";
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App } from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import { createMailbox, deleteMailbox, listDomains, listMailboxes, patchMailbox, type DomainRow, type MailboxRow } from "../api.ts";
/** # 函数
* 邮箱页组件
*/
export function MailboxesPage() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<MailboxRow[]>([]);
const [domains, setDomains] = useState<DomainRow[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const load = useCallback(async () => {
setLoading(true);
try {
const [mb, dm] = await Promise.all([listMailboxes(), listDomains()]);
setItems(mb.items);
setDomains(dm.items);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const toggleStatus = async (row: MailboxRow) => {
await patchMailbox(row.id, { status: row.status === "active" ? "disabled" : "active" });
void load();
};
return (
<div>
<div className="page-toolbar">
<span className="muted">catch-all 邮箱(*@domain)会接收该域名下所有未精确匹配的邮件。</span>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)} disabled={domains.length === 0}>
添加邮箱
</Button>
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
columns={[
{
title: "地址",
dataIndex: "address",
render: (v: string, row: MailboxRow) => (
<span>
<span className="mono">{v}</span> {row.is_catch_all ? <Tag color="purple">catch-all</Tag> : null}
</span>
),
},
{ title: "域名", dataIndex: "domain_name", width: 180 },
{
title: "状态",
dataIndex: "status",
width: 100,
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>,
},
{
title: "保留(秒)",
dataIndex: "retention_seconds",
width: 110,
render: (v: number | null) => v ?? <span className="muted">默认 86400</span>,
},
{ title: "邮件数", dataIndex: "email_count", width: 90 },
{
title: "启用",
width: 80,
render: (_: unknown, row: MailboxRow) => (
<Switch checked={row.status === "active"} onChange={() => void toggleStatus(row)} />
),
},
{
title: "操作",
width: 90,
render: (_: unknown, row: MailboxRow) => (
<Button
size="small"
danger
onClick={() =>
modal.confirm({
title: `删除邮箱 ${row.address}?`,
onOk: async () => {
await deleteMailbox(row.id);
message.success("已删除");
void load();
},
})
}
>
删除
</Button>
),
},
]}
/>
</Card>
<Modal
title="添加邮箱"
open={open}
onCancel={() => setOpen(false)}
okText="添加"
cancelText="取消"
destroyOnHidden
onOk={async () => {
const values = await form.validateFields();
try {
const res = await createMailbox({
domain_id: values.domain_id,
address: values.address,
retention_seconds: values.retention_seconds || undefined,
catch_all: values.catch_all,
});
message.success(`已创建 ${res.address}`);
setOpen(false);
form.resetFields();
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "创建失败");
}
}}
>
<Form form={form} layout="vertical">
<Form.Item name="domain_id" label="域名" rules={[{ required: true, message: "请选择域名" }]}>
<Select
options={domains.map((d) => ({ value: d.id, label: d.name }))}
placeholder="选择域名"
/>
</Form.Item>
<Form.Item name="address" label="地址(可只填本地部分)" rules={[{ required: true, message: "请输入地址" }]}>
<Input placeholder="hello" />
</Form.Item>
<Form.Item name="retention_seconds" label="保留秒数(留空用全局默认 1 天)">
<InputNumber min={60} style={{ width: "100%" }} placeholder="86400" />
</Form.Item>
<Form.Item name="catch_all" label="设为该域名 catch-all" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+349
View File
@@ -0,0 +1,349 @@
/** 节点页:Add Node(OAuth 自动安装)、安装进度、凭据轮换 */
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
Button,
Card,
Drawer,
Form,
Input,
Modal,
Space,
Table,
Tag,
Timeline,
Typography,
App,
Alert,
} from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import {
createNode,
deleteNode,
getNode,
getNodeIngressSecret,
listNodes,
oauthMode,
oauthStart,
rotateNode,
type NodeDetail,
type NodeRow,
} from "../api.ts";
/** # 成员变量: 节点状态 -> Tag 颜色 **/
const STATUS_COLOR: Record<string, string> = {
pending_oauth: "orange",
provisioning: "processing",
online: "success",
offline: "default",
error: "error",
};
/** # 成员变量: 安装步骤中文标签(与后端 PROVISION_STEPS 对应) **/
const STEP_LABEL: Record<string, string> = {
oauth: "Cloudflare OAuth 授权",
identity: "Node identity 与 credential",
discover: "Account / Zone 发现",
email_worker: "Email Worker 创建/更新",
email_routing: "Email Routing 配置",
domains: "Domain 创建",
mailboxes: "Mailbox 创建",
};
/** # 函数
* 节点页组件
*/
export function NodesPage() {
const { message, modal } = App.useApp();
const [params] = useSearchParams();
const [items, setItems] = useState<NodeRow[]>([]);
const [loading, setLoading] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [form] = Form.useForm();
const [detail, setDetail] = useState<NodeDetail | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listNodes()).items);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
// OAuth 回调携带 ?provision=<id> 时自动打开详情
const provisionId = params.get("provision");
if (provisionId) {
void (async () => {
try {
setDetail(await getNode(provisionId));
setDetailOpen(true);
} catch {
// ignore
}
})();
}
}, [load, params]);
/** Add Node 提交:创建节点后进入 OAuth */
const submitAdd = async () => {
const values = await form.validateFields();
const created = await createNode(values.name, values.ingress_url || undefined);
setAddOpen(false);
form.resetFields();
message.success("节点已创建,开始 Cloudflare 授权");
await startOAuth(created.node_id);
};
/** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页 */
const startOAuth = async (nodeId: string) => {
const mode = (await oauthMode()).mode;
const { url } = await oauthStart(nodeId);
if (mode === "cloudflare") {
window.location.href = url;
} else {
window.location.href = url; // /oauth/sim/authorize?state=...
}
};
const openDetail = async (id: string) => {
setDetail(await getNode(id));
setDetailOpen(true);
};
return (
<div>
<div className="page-toolbar">
<span className="muted">
点击「添加节点」后完成 Cloudflare OAuth,系统自动安装 Email Worker / Email Routing / Domain / Mailbox,
全程无需在服务器执行任何安装命令。
</span>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddOpen(true)}>
添加节点
</Button>
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
onRow={(row) => ({ onClick: () => void openDetail(row.id), style: { cursor: "pointer" } })}
columns={[
{ title: "名称", dataIndex: "name" },
{
title: "状态",
dataIndex: "status",
width: 120,
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
},
{ title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", render: (v: string | null) => v ?? "-" },
{ title: "版本", dataIndex: "version", width: 90, render: (v: string | null) => v ?? "-" },
{
title: "最近心跳",
dataIndex: "last_seen_at",
width: 190,
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
},
{
title: "操作",
width: 150,
render: (_: unknown, row: NodeRow) => (
<Space>
<Button
size="small"
disabled={row.status !== "pending_oauth"}
onClick={(e) => {
e.stopPropagation();
void startOAuth(row.id);
}}
>
授权
</Button>
<Button
size="small"
danger
onClick={(e) => {
e.stopPropagation();
modal.confirm({
title: `删除节点 ${row.name}?`,
content: "将撤销节点凭据并标记为 revoked。",
onOk: async () => {
await deleteNode(row.id);
message.success("已删除");
void load();
},
});
}}
>
删除
</Button>
</Space>
),
},
]}
/>
</Card>
<AddNodeModal open={addOpen} form={form} onOk={() => void submitAdd()} onCancel={() => setAddOpen(false)} />
<NodeDetailDrawer
detail={detail}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onReload={async () => {
if (detail) setDetail(await getNode(detail.node.id));
void load();
}}
/>
</div>
);
}
/** # 函数
* 添加节点弹窗
*/
function AddNodeModal({
open,
form,
onOk,
onCancel,
}: {
open: boolean;
form: ReturnType<typeof Form.useForm>[0];
onOk: () => void;
onCancel: () => void;
}) {
return (
<Modal
title="添加节点"
open={open}
onOk={onOk}
onCancel={onCancel}
okText="创建并授权"
cancelText="取消"
destroyOnHidden
>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="创建后将跳转 Cloudflare OAuth;授权完成即自动完成全部安装。"
/>
<Form form={form} layout="vertical">
<Form.Item name="name" label="节点名称" rules={[{ required: true, message: "请输入名称" }]}>
<Input placeholder="例如:Local Node / Node-JP" />
</Form.Item>
<Form.Item
name="ingress_url"
label="Node Ingress 地址"
initialValue="http://127.0.0.1:18788/ingress/email"
extra="Email Worker 回源 Node 的地址;生产环境填 Node 的公网 HTTPS 地址"
>
<Input />
</Form.Item>
</Form>
</Modal>
);
}
/** # 函数
* 节点详情抽屉:安装步骤时间线 + 域名 + enrollment/凭据管理
*/
function NodeDetailDrawer({
detail,
open,
onClose,
onReload,
}: {
detail: NodeDetail | null;
open: boolean;
onClose: () => void;
onReload: () => void;
}) {
const { message, modal } = App.useApp();
const [showSecret, setShowSecret] = useState(false);
if (!detail) return null;
const steps = detail.steps;
return (
<Drawer title={`节点:${detail.node.name}`} size="large" open={open} onClose={onClose}>
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<div>
<Typography.Title level={5}>安装进度</Typography.Title>
<Timeline
items={Object.entries(STEP_LABEL).map(([key, label]) => {
const st = steps[key]?.status ?? "pending";
return {
color: st === "done" ? "green" : st === "error" ? "red" : st === "running" ? "blue" : "gray",
children: (
<span>
{label}{" "}
{st === "error" && steps[key]?.error ? (
<Typography.Text type="danger">{steps[key].error}</Typography.Text>
) : (
<Tag style={{ marginLeft: 6 }}>{st}</Tag>
)}
</span>
),
};
})}
/>
</div>
<Card size="small" title="已配置域名">
{detail.domains.length === 0 ? (
<span className="muted">暂无</span>
) : (
detail.domains.map((d) => (
<div key={d.id}>
<Tag color="blue">{d.name}</Tag>
<span className="muted mono">{d.worker_id ?? d.status}</span>
</div>
))
)}
</Card>
<Card size="small" title="凭据管理">
<Space orientation="vertical" style={{ width: "100%" }}>
<Space wrap>
<Button
onClick={async () => {
const res = await rotateNode(detail.node.id);
modal.info({
title: "新的 Enrollment Token(仅显示一次)",
content: <Typography.Paragraph copyable className="mono">{res.enrollment_token}</Typography.Paragraph>,
});
onReload();
}}
>
轮换凭据(生成新 Enrollment Token)
</Button>
<Button
onClick={async () => {
const res = await getNodeIngressSecret(detail.node.id);
setShowSecret(true);
message.info(`Ingress Secret:${res.ingress_secret}`);
}}
>
查看 Ingress Secret
</Button>
</Space>
<Typography.Text type="secondary" className="mono">
enrollment 有效期至 {detail.node.enrollment_expires_at ?? "-"}
</Typography.Text>
</Space>
</Card>
</Space>
{showSecret && null}
</Drawer>
);
}
+55
View File
@@ -0,0 +1,55 @@
/** sim 模式的 Cloudflare 授权模拟页:确认后调用 approve,再跳回节点页 */
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button, Card, Space, App } from "antd";
import { oauthSimApprove } from "../api.ts";
/** # 函数
* 模拟授权页(仅本地 sim 模式;真实部署直接跳 Cloudflare 授权页)
*/
export function OAuthSimPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const { message } = App.useApp();
const state = params.get("state") ?? "";
const [busy, setBusy] = useState(false);
const approve = async () => {
setBusy(true);
try {
const res = await oauthSimApprove(state);
if (res.ok) {
message.success("Cloudflare 授权与自动安装完成");
navigate(`/nodes?provision=${encodeURIComponent(res.node_id)}`, { replace: true });
} else {
message.error(res.error ?? "安装失败");
navigate("/nodes", { replace: true });
}
} catch (err) {
message.error(err instanceof Error ? err.message : "授权失败");
navigate("/nodes", { replace: true });
} finally {
setBusy(false);
}
};
// state 缺失直接回节点页
useEffect(() => {
if (!state) navigate("/nodes", { replace: true });
}, [state, navigate]);
return (
<div className="login-page">
<Card className="login-card" title="Cloudflare 授权(本地模拟)" styles={{ header: { textAlign: "center" } }}>
<p>sim 模式下,该页面模拟 Cloudflare OAuth 授权同意流程。</p>
<p className="muted">真实部署时,这里会跳转到 dash.cloudflare.com 的授权页面。</p>
<Space style={{ width: "100%", justifyContent: "center", marginTop: 16 }}>
<Button onClick={() => navigate("/nodes")}>拒绝</Button>
<Button type="primary" loading={busy} onClick={() => void approve()}>
同意授权
</Button>
</Space>
</Card>
</div>
);
}
+266
View File
@@ -0,0 +1,266 @@
/** Webhooks 页:CRUD、secret 展示、测试投递、投递日志 */
import { useCallback, useEffect, useState } from "react";
import {
Button,
Card,
Drawer,
Form,
Input,
Modal,
Space,
Switch,
Table,
Tag,
Typography,
App,
} from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import {
createWebhook,
deleteWebhook,
listDeliveries,
listWebhooks,
patchWebhook,
redeliver,
rotateWebhookSecret,
testWebhook,
type DeliveryRow,
type WebhookRow,
} from "../api.ts";
/** # 成员变量: 投递状态颜色 **/
const DELIVERY_COLOR: Record<string, string> = {
pending: "processing",
success: "success",
failed: "warning",
exhausted: "error",
};
/** # 函数
* Webhooks 页组件
*/
export function WebhooksPage() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<WebhookRow[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const [deliveries, setDeliveries] = useState<{ hook: WebhookRow; rows: DeliveryRow[] } | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listWebhooks()).items);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
/** 展示一次性 secret */
const showSecretModal = (title: string, secret: string) => {
modal.info({
title,
width: 560,
content: (
<div>
<Typography.Paragraph type="warning">secret 仅此一次显示,请立即保存。</Typography.Paragraph>
<Typography.Paragraph copyable className="mono">
{secret}
</Typography.Paragraph>
</div>
),
});
};
return (
<div>
<div className="page-toolbar">
<span className="muted">
{"邮件入库后推送 email.received 事件;签名头 X-WmailHub-Signature = HMAC-SHA256(secret, `${timestamp}.${body}`)。"}
</span>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)}>
添加 Webhook
</Button>
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
columns={[
{ title: "名称", dataIndex: "name" },
{ title: "Endpoint", dataIndex: "endpoint", ellipsis: true, className: "mono" },
{
title: "投递",
width: 140,
render: (_: unknown, row: WebhookRow) => (
<span>
{row.success_count}/{row.delivery_count}
</span>
),
},
{
title: "启用",
width: 80,
render: (_: unknown, row: WebhookRow) => (
<Switch
checked={row.enabled === 1}
onChange={async (v) => {
await patchWebhook(row.id, { enabled: v });
void load();
}}
/>
),
},
{
title: "操作",
width: 280,
render: (_: unknown, row: WebhookRow) => (
<Space wrap>
<Button
size="small"
onClick={async () => {
const res = await testWebhook(row.id);
if (res.ok) message.success("测试投递成功");
else message.error(`测试投递失败:${res.error ?? res.status}`);
void load();
}}
>
测试
</Button>
<Button
size="small"
onClick={async () => {
setDeliveries({ hook: row, rows: (await listDeliveries(row.id)).items });
}}
>
日志
</Button>
<Button
size="small"
onClick={async () => {
const res = await rotateWebhookSecret(row.id);
showSecretModal("新 Secret", res.secret);
}}
>
轮换 Secret
</Button>
<Button
size="small"
danger
onClick={() =>
modal.confirm({
title: `删除 Webhook ${row.name}?`,
onOk: async () => {
await deleteWebhook(row.id);
message.success("已删除");
void load();
},
})
}
>
删除
</Button>
</Space>
),
},
]}
/>
</Card>
<Modal
title="添加 Webhook"
open={open}
onCancel={() => setOpen(false)}
okText="创建"
cancelText="取消"
destroyOnHidden
onOk={async () => {
const values = await form.validateFields();
try {
const res = await createWebhook(values.name, values.endpoint);
setOpen(false);
form.resetFields();
void load();
showSecretModal("Webhook Secret(仅显示一次)", res.secret);
} catch (err) {
message.error(err instanceof Error ? err.message : "创建失败");
}
}}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入名称" }]}>
<Input placeholder="CRM 通知" />
</Form.Item>
<Form.Item
name="endpoint"
label="Endpoint URL"
rules={[
{ required: true, message: "请输入 URL" },
{
validator: (_, v: string) =>
/^https?:\/\//.test(v ?? "") ? Promise.resolve() : Promise.reject(new Error("必须为 http(s) URL")),
},
]}
extra="禁止 localhost / 私网 / 内网地址(SSRF 防护)"
>
<Input placeholder="https://example.com/hooks/wmailhub" />
</Form.Item>
</Form>
</Modal>
<Drawer
title={`投递日志:${deliveries?.hook.name ?? ""}`}
size="large"
open={Boolean(deliveries)}
onClose={() => setDeliveries(null)}
>
<Table
rowKey="id"
size="small"
dataSource={deliveries?.rows ?? []}
pagination={false}
columns={[
{ title: "邮件", dataIndex: "subject", ellipsis: true },
{
title: "状态",
dataIndex: "status",
width: 100,
render: (v: string) => <Tag color={DELIVERY_COLOR[v] ?? "default"}>{v}</Tag>,
},
{ title: "尝试", dataIndex: "attempt_count", width: 60 },
{
title: "错误",
dataIndex: "last_error",
ellipsis: true,
render: (v: string | null) => v ?? "-",
},
{
title: "操作",
width: 90,
render: (_: unknown, row: DeliveryRow) => (
<Button
size="small"
onClick={async () => {
await redeliver(row.id);
message.success("已加入重试队列");
}}
>
重投
</Button>
),
},
]}
/>
</Drawer>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
/** 全局样式:100vh 框架布局 + 少量辅助类(纯手写,不引入 Tailwind 避免与 antd 层叠冲突) */
html,
body,
#root {
height: 100%;
margin: 0;
}
* {
box-sizing: border-box;
}
.app-layout {
height: 100vh;
overflow: hidden;
}
.app-sider {
border-right: 1px solid rgba(5, 5, 5, 0.06);
}
.app-sider .brand {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px 12px;
}
.app-sider .brand img {
width: 34px;
height: 34px;
}
.app-sider .brand .name {
font-size: 18px;
font-weight: 700;
color: rgba(0, 0, 0, 0.88);
letter-spacing: 0.2px;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
}
.app-header .title {
font-size: 20px;
font-weight: 650;
}
.app-header .subtitle {
font-size: 12.5px;
color: rgba(0, 0, 0, 0.45);
margin-top: 1px;
}
.app-content {
overflow: auto;
padding: 24px;
}
.page-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.page-toolbar .filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
}
.muted {
color: rgba(0, 0, 0, 0.45);
}
.email-html-frame {
width: 100%;
border: 1px solid rgba(5, 5, 5, 0.1);
border-radius: 8px;
background: #fff;
min-height: 320px;
}
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(160deg, #f0f5ff 0%, #ffffff 55%);
}
.login-card {
width: 380px;
box-shadow: 0 8px 40px rgba(47, 84, 235, 0.10);
}
.login-brand {
display: flex;
align-items: center;
gap: 12px;
justify-content: center;
margin-bottom: 8px;
}
.login-brand img {
width: 44px;
height: 44px;
}
.login-brand .name {
font-size: 26px;
font-weight: 750;
}
+25
View File
@@ -0,0 +1,25 @@
/** antd v6 主题配置:默认主题 + 少量 token 覆写(主色与品牌一致) */
import type { ThemeConfig } from "antd";
/** # 成员变量: 品牌主色 **/
export const BRAND_COLOR = "#2f54eb";
/** # 成员变量: antd 主题 */
export const antdTheme: ThemeConfig = {
token: {
colorPrimary: BRAND_COLOR,
borderRadius: 8,
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
},
components: {
Layout: {
siderBg: "#ffffff",
headerBg: "#ffffff",
headerHeight: 64,
},
Card: {
headerHeight: 56,
},
},
};
+74
View File
@@ -0,0 +1,74 @@
/**
* Console 应用装配(Hono,Workers/Bun 双运行时)。
*
* 路由分区:
* /api/health 健康检查
* /api/auth/* 用户登录/登出/会话(cookie)
* /api/nodes|domains|mailboxes|emails 管理资源 CRUD(会话保护)
* /api/webhooks/* Webhook 管理(会话保护)
* /api/keys/* API/MCP Key 管理(会话保护)
* /api/oauth/* Cloudflare OAuth(会话保护)
* /api/admin/* 版本/统计/Cron(会话保护)
* /api/internal/* Node 内部协议(HMAC 签名)
* /api/v1/mcp MCP JSON-RPC(Bearer MCP Key wmh_)
* /api/v1/* 外部 API(Bearer API Key wmh_)
* /* 前端 SPA 静态资源
*/
import { Hono } from "hono";
import type { Env, AppVars } from "./env.ts";
import { ensureSeeded } from "./db.ts";
import { csrfOriginCheck } from "./middleware.ts";
import { authRoutes } from "./auth.ts";
import { internalRoutes } from "./routes/internal.ts";
import { resourceRoutes } from "./routes/resources.ts";
import { webhookRoutes } from "./routes/webhooks.ts";
import { keyRoutes } from "./routes/keys.ts";
import { oauthRoutes } from "./routes/oauth.ts";
import { apiV1Routes } from "./routes/apiv1.ts";
import { mcpRoutes } from "./routes/mcp.ts";
import { adminRoutes } from "./routes/admin.ts";
import { APP_NAME, APP_VERSION } from "./version.ts";
/** # 函数
* 构造 Console Hono 应用实例
* 参数: opts.serveStatic - 静态资源处理器(本地运行时注入;Workers 端走 ASSETS 绑定)
*/
export function createApp(
opts: {
serveStatic?: (request: Request) => Promise<Response>;
} = {},
): Hono<{ Bindings: Env; Variables: AppVars }> {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
// 每个请求先保证种子数据(幂等,内存标志去重)
app.use("*", async (c, next) => {
await ensureSeeded(c.env);
await next();
});
app.use("/api/*", csrfOriginCheck);
app.get("/api/health", (c) => c.json({ ok: true, name: APP_NAME, version: APP_VERSION }));
app.route("/api/auth", authRoutes());
app.route("/api/internal", internalRoutes());
app.route("/api/oauth", oauthRoutes());
app.route("/api/webhooks", webhookRoutes());
app.route("/api/keys", keyRoutes());
app.route("/api/admin", adminRoutes());
app.route("/api/v1/mcp", mcpRoutes());
app.route("/api/v1", apiV1Routes());
app.route("/api", resourceRoutes());
// 静态资源 / SPA fallback
app.get("/*", async (c) => {
const path = new URL(c.req.url).pathname;
if (path.startsWith("/api/")) return c.json({ error: "not found" }, 404);
if (opts.serveStatic) return opts.serveStatic(c.req.raw);
if (c.env.ASSETS) return c.env.ASSETS.fetch(c.req.raw);
return c.text("frontend assets not configured", 404);
});
return app;
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Console 用户认证:用户名密码登录 + HMAC 签名会话 cookie。
* cookie 值: base64url(payload) "." hex(HMAC-SHA256(sessionSecret, payload))
*/
import { hmacSha256Hex, timingSafeEqualHex, verifyPassword } from "../../shared/src/index.ts";
import type { Env } from "./env.ts";
import { getSessionSecret } from "./db.ts";
import { nowIso, readJson } from "./util.ts";
import { Hono } from "hono";
/** # 成员变量: 会话 cookie 名称 **/
export const SESSION_COOKIE = "wmh_session";
/** # 成员变量: 会话有效期(秒),默认 7 天 **/
const SESSION_TTL_SECONDS = 7 * 24 * 3600;
/** # 成员变量: 会话 payload **/
interface SessionPayload {
uid: string;
username: string;
exp: number;
}
/** # 函数
* 签发会话 token
*/
export async function issueSessionToken(env: Env, uid: string, username: string): Promise<string> {
const payload: SessionPayload = {
uid,
username,
exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS,
};
const body = base64UrlEncode(JSON.stringify(payload));
const secret = await getSessionSecret(env);
const sig = await hmacSha256Hex(secret, body);
return `${body}.${sig}`;
}
/** # 函数
* 校验会话 token,返回 payload;无效/过期返回 null
*/
export async function verifySessionToken(
env: Env,
token: string | undefined,
): Promise<SessionPayload | null> {
if (!token) return null;
const dot = token.lastIndexOf(".");
if (dot <= 0) return null;
const body = token.slice(0, dot);
const sig = token.slice(dot + 1);
const secret = await getSessionSecret(env);
const expected = await hmacSha256Hex(secret, body);
if (!timingSafeEqualHex(expected, sig)) return null;
let payload: SessionPayload;
try {
payload = JSON.parse(base64UrlDecode(body)) as SessionPayload;
} catch {
return null;
}
if (!payload?.uid || typeof payload.exp !== "number") return null;
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
return payload;
}
/** # 函数
* 从 Request 中取会话 cookie
*/
export function readSessionCookie(request: Request): string | undefined {
const cookie = request.headers.get("Cookie") ?? "";
for (const part of cookie.split(";")) {
const [k, ...rest] = part.trim().split("=");
if (k === SESSION_COOKIE) return rest.join("=");
}
return undefined;
}
/** # 函数
* 构造 Set-Cookie 会话头
*/
export function sessionSetCookie(token: string): string {
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_TTL_SECONDS}`;
}
/** # 函数
* 构造清除会话 cookie 头
*/
export function clearSessionCookie(): string {
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
}
function base64UrlEncode(s: string): string {
const bytes = new TextEncoder().encode(s);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function base64UrlDecode(s: string): string {
const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4));
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new TextDecoder().decode(bytes);
}
/** # 函数
* 装配认证路由:POST /login、POST /logout、GET /me
*/
export function authRoutes() {
const app = new Hono<{ Bindings: Env }>();
/** 登录:用户名密码 → 签名会话 cookie。默认账号 admin/admin(首次启动自动播种) */
app.post("/login", async (c) => {
const { username, password } = await readJson<{ username?: string; password?: string }>(c.req.raw);
if (!username || !password) {
return c.json({ error: "username and password required" }, 400);
}
const user = await c.env.DB.prepare("SELECT id, username, password_hash FROM users WHERE username = ?")
.bind(username)
.first<{ id: string; username: string; password_hash: string }>();
if (!user || !(await verifyPassword(password, user.password_hash))) {
return c.json({ error: "invalid username or password" }, 401);
}
const token = await issueSessionToken(c.env, user.id, user.username);
c.header("Set-Cookie", sessionSetCookie(token));
return c.json({ ok: true, user: { id: user.id, username: user.username } });
});
/** 登出:清除会话 cookie */
app.post("/logout", (c) => {
c.header("Set-Cookie", clearSessionCookie());
return c.json({ ok: true });
});
/** 当前登录用户信息 */
app.get("/me", async (c) => {
const payload = await verifySessionToken(c.env, readSessionCookie(c.req.raw));
if (!payload) return c.json({ error: "unauthorized" }, 401);
return c.json({ user: { id: payload.uid, username: payload.username }, now: nowIso() });
});
return app;
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Console 数据访问助手:settings KV、主密钥、初始化种子(admin/admin)。
* 全部基于 D1 API 子集,Workers 与本地运行时通用。
*/
import type { Env } from "./env.ts";
import {
aesEncrypt,
aesDecrypt,
deriveAesKey,
hashPassword,
ids,
sha256Hex,
} from "../../shared/src/index.ts";
import { nowIso } from "./util.ts";
/** # 函数
* 读取 settings 表配置
*/
export async function getSetting(env: Env, key: string): Promise<string | null> {
const row = await env.DB.prepare("SELECT value FROM settings WHERE key = ?").bind(key).first<{ value: string }>();
return row?.value ?? null;
}
/** # 函数
* 写入 settings 表配置(upsert)
*/
export async function setSetting(env: Env, key: string, value: string): Promise<void> {
await env.DB.prepare(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.bind(key, value)
.run();
}
/** # 函数
* 获取应用主密钥(用于会话签名与敏感字段加密)。
* 优先 env.SESSION_SECRET;否则首次自动生成 64 字符随机串并持久化到 settings,重启不变。
*/
export async function getMasterSecret(env: Env): Promise<string> {
if (env.SESSION_SECRET) return env.SESSION_SECRET;
const existing = await getSetting(env, "app_secret");
if (existing) return existing;
const secret = crypto.getRandomValues(new Uint8Array(32));
let hex = "";
for (const b of secret) hex += b.toString(16).padStart(2, "0");
await setSetting(env, "app_secret", hex);
return hex;
}
/** # 函数
* 获取 AES 加密密钥(OAuth token、node credential secret、ingress secret 加密存储)
*/
export async function getAesKey(env: Env): Promise<CryptoKey> {
const master = await getMasterSecret(env);
return deriveAesKey(master + ":encrypt");
}
/** # 函数
* 获取会话签名密钥
*/
export async function getSessionSecret(env: Env): Promise<string> {
const master = await getMasterSecret(env);
return master + ":session";
}
/** # 成员变量: 种子初始化标志(每个运行实例只需一次) **/
let seeded = false;
/** # 函数
* 初始化种子数据:确保默认管理员 admin/admin 存在(仅在用户表为空时创建)。
* Console 本地启动与 Workers 首个请求时都会调用,幂等。
*/
export async function ensureSeeded(env: Env): Promise<void> {
if (seeded) return;
const count = await env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>();
if (!count || count.n === 0) {
await env.DB.prepare(
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
)
.bind(ids.user(), "admin", await hashPassword("admin"), nowIso())
.run();
}
seeded = true;
}
/** # 函数
* AES 加密敏感字段(以应用主密钥)
*/
export async function encryptSecret(env: Env, plaintext: string): Promise<string> {
return aesEncrypt(await getAesKey(env), plaintext);
}
/** # 函数
* 解密敏感字段
*/
export async function decryptSecret(env: Env, blob: string): Promise<string> {
return aesDecrypt(await getAesKey(env), blob);
}
/** # 函数
* 计算凭据 SHA-256 摘要(密钥入库形态)
*/
export function keyHash(key: string): Promise<string> {
return sha256Hex(key);
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Console 运行环境类型:同时兼容 Cloudflare Workers 与本地 Bun 运行时。
* D1Database / R2Bucket 为本项目实际用到的最小接口子集。
*/
/** # 成员变量: D1 预处理语句最小接口 **/
export interface D1PreparedStatement {
bind(...values: unknown[]): D1PreparedStatement;
first<T = Record<string, unknown>>(colName?: string): Promise<T | null>;
all<T = Record<string, unknown>>(): Promise<{ results: T[]; success?: boolean; meta?: unknown }>;
run(): Promise<{ success: boolean; meta: { changes?: number; last_row_id?: number } }>;
}
/** # 成员变量: D1 数据库最小接口 **/
export interface D1Database {
prepare(query: string): D1PreparedStatement;
batch<T = unknown>(statements: D1PreparedStatement[]): Promise<T[]>;
exec(query: string): Promise<unknown>;
}
/** # 成员变量: R2 对象最小接口 **/
export interface R2ObjectBody {
arrayBuffer(): Promise<ArrayBuffer>;
text(): Promise<string>;
json(): Promise<unknown>;
}
/** # 成员变量: R2 桶最小接口 **/
export interface R2Bucket {
put(
key: string,
value: ArrayBuffer | Uint8Array | string | ReadableStream | Blob,
options?: { httpMetadata?: Record<string, string> },
): Promise<unknown>;
get(key: string): Promise<R2ObjectBody | null>;
delete(keys: string | string[]): Promise<void>;
head(key: string): Promise<{ key: string; size: number } | null>;
}
/** # 成员变量: Console env 绑定 **/
export interface Env {
/** D1 数据库(本地运行时由 LocalD1 提供) **/
DB: D1Database;
/** R2 桶(本地运行时由 LocalR2 提供) **/
R2: R2Bucket;
/** Workers 静态资源绑定(本地运行时为空,由 Bun 直接读文件) **/
ASSETS?: { fetch(request: Request): Promise<Response> };
/** 会话/加密主密钥;未提供时自动生成并持久化到 settings 表 **/
SESSION_SECRET?: string;
/** Console 对外地址(用于 OAuth redirect_uri 与展示) **/
PUBLIC_BASE_URL?: string;
/** Cloudflare OAuth 客户端;两个都配置时走真实 OAuth,否则进入本地 sim 模式 **/
CF_OAUTH_CLIENT_ID?: string;
CF_OAUTH_CLIENT_SECRET?: string;
CF_OAUTH_REDIRECT_URI?: string;
/** 强制使用 sim 模式("1") **/
WMAILHUB_SIM_CF?: string;
/** 本地联调:允许 webhook endpoint 指向私网/localhost("1"),生产严禁开启 **/
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string;
}
/** # 成员变量: Workers 执行上下文最小接口 **/
export interface WorkersExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
/** # 成员变量: Workers Cron 触发事件最小接口 **/
export interface ScheduledEvent {
cron: string;
scheduledTime: number;
}
/** # 成员变量: Hono context 变量 **/
export interface AppVars {
/** 已登录用户 ID(cookie 会话) **/
userId?: string;
/** 已认证 API/MCP Key 信息 **/
apiKeyKind?: "api" | "mcp";
apiKeyId?: string;
/** internal 路由中已读出的原始请求体(nodeAuth 中间件缓存) **/
rawBody?: Uint8Array;
/** internal 路由中通过校验的 credential id **/
nodeCredentialId?: string;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* LocalD1:基于 bun:sqlite 的 D1 API 子集实现(仅本地开发/测试用)。
* 与 Cloudflare D1 的 prepare/bind/first/all/run/batch/exec 行为对齐:
* - all() -> { results: rows }
* - first()-> row | null
* - run() -> { success, meta: { changes, last_row_id } }
* - bind(undefined) 宽容为 null
*/
import { Database, type SQLQueryBindings } from "bun:sqlite";
import type { D1Database, D1PreparedStatement } from "../env.ts";
/** # 成员变量: LocalD1 实例 **/
export class LocalD1 implements D1Database {
/** # 成员变量: 底层 SQLite 句柄 **/
readonly db: Database;
/** # 成员变量 **/
constructor(db: Database) {
this.db = db;
}
prepare(query: string): D1PreparedStatement {
return new LocalStatement(this.db, query);
}
async batch<T>(statements: D1PreparedStatement[]): Promise<T[]> {
const out: T[] = [];
const tx = this.db.transaction(() => {
for (const stmt of statements) {
out.push(this.db.exec((stmt as LocalStatement).sql) as T);
}
});
tx();
return out;
}
async exec(query: string): Promise<unknown> {
return this.db.exec(query);
}
}
/** # 成员变量: 单条预处理语句 **/
class LocalStatement implements D1PreparedStatement {
/** # 成员变量: 绑定参数 **/
private params: unknown[] = [];
/** # 成员变量 **/
constructor(
private db: Database,
readonly sql: string,
) {}
bind(...values: unknown[]): D1PreparedStatement {
const stmt = new LocalStatement(this.db, this.sql);
stmt.params = values.map((v) => (v === undefined ? null : v));
return stmt;
}
async first<T = Record<string, unknown>>(_colName?: string): Promise<T | null> {
const row = this.db.prepare(this.sql).get(...(this.params as SQLQueryBindings[])) as T | null;
return row ?? null;
}
async all<T = Record<string, unknown>>(): Promise<{ results: T[]; success?: boolean }> {
const rows = this.db.prepare(this.sql).all(...(this.params as SQLQueryBindings[])) as T[];
return { results: rows, success: true };
}
async run(): Promise<{ success: boolean; meta: { changes?: number; last_row_id?: number } }> {
const meta = this.db.prepare(this.sql).run(...(this.params as SQLQueryBindings[]));
return {
success: true,
meta: { changes: Number(meta.changes), last_row_id: Number(meta.lastInsertRowid) },
};
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Console 本地运行时入口(开发用):
* bun console/src/local/main.ts
*
* - bun:sqlite 文件库(LocalD1)+ 文件系统 R2(LocalR2)
* - 启动时自动应用 migrations/*.sql(幂等)与 admin/admin 种子
* - 每 60s 本地模拟一次 Cloudflare Cron(TTL 清理 + Webhook 重试)
* - 静态资源直接读 console/public(SPA fallback index.html)
*/
import { Database } from "bun:sqlite";
import { readdirSync, existsSync } from "node:fs";
import path from "node:path";
import { createApp } from "../app.ts";
import { LocalD1 } from "./d1.ts";
import { LocalR2 } from "./r2.ts";
import type { Env } from "../env.ts";
import { runCron } from "../services/cleanup.ts";
import { processDueDeliveries } from "../services/webhook_delivery.ts";
import { resolveHost } from "../ssrf-resolver.ts";
import { APP_NAME, APP_VERSION } from "../version.ts";
const ROOT = path.resolve(import.meta.dir, "../.."); // console/
const DATA_DIR = process.env.WMAILHUB_DATA_DIR ?? path.join(ROOT, "data");
const PUBLIC_DIR = path.join(ROOT, "public");
const PORT = Number(process.env.WMAILHUB_CONSOLE_PORT ?? 18787);
// ---- 数据库:自动迁移 -------------------------------------------------------
await mkdirSafe(DATA_DIR);
const db = new Database(path.join(DATA_DIR, "wmailhub.local.sqlite"), { create: true, strict: true });
db.run("PRAGMA journal_mode = WAL;");
const migrationsDir = path.join(ROOT, "..", "migrations");
if (existsSync(migrationsDir)) {
for (const file of readdirSync(migrationsDir).sort()) {
if (!file.endsWith(".sql")) continue;
const sql = await Bun.file(path.join(migrationsDir, file)).text();
db.exec(sql);
console.log(`[console] migration applied: ${file}`);
}
}
// ---- env 绑定 ---------------------------------------------------------------
const env: Env = {
DB: new LocalD1(db),
R2: new LocalR2(path.join(DATA_DIR, "r2")),
SESSION_SECRET: process.env.WMAILHUB_SESSION_SECRET,
PUBLIC_BASE_URL: process.env.WMAILHUB_PUBLIC_BASE_URL ?? `http://127.0.0.1:${PORT}`,
WMAILHUB_SIM_CF: process.env.WMAILHUB_SIM_CF ?? "1",
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: process.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS ?? "1",
};
// ---- 静态资源 ----------------------------------------------------------------
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".jpg": "image/jpeg",
".json": "application/json",
".woff2": "font/woff2",
".map": "application/json",
};
async function serveStatic(request: Request): Promise<Response> {
const url = new URL(request.url);
let pathname = decodeURIComponent(url.pathname);
if (pathname === "/") pathname = "/index.html";
const file = path.normalize(path.join(PUBLIC_DIR, pathname));
if (file.startsWith(PUBLIC_DIR) && existsSync(file) && !existsSync(file + "/")) {
const ext = path.extname(file).toLowerCase();
return new Response(Bun.file(file), {
headers: { "Content-Type": MIME[ext] ?? "application/octet-stream" },
});
}
// SPA fallback
const index = path.join(PUBLIC_DIR, "index.html");
if (existsSync(index)) {
return new Response(Bun.file(index), {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return new Response("frontend not built. run: bun run build", { status: 404 });
}
// ---- 启动 ---------------------------------------------------------------------
const app = createApp({ serveStatic });
const server = Bun.serve({
port: PORT,
hostname: "0.0.0.0",
fetch: (req) => app.fetch(req, env),
});
console.log(
JSON.stringify({
t: new Date().toISOString(),
lvl: "info",
msg: "console started",
service: APP_NAME,
version: APP_VERSION,
url: `http://127.0.0.1:${PORT}`,
login: "admin/admin (default)",
}),
);
// 本地模拟 Cloudflare Cron:每 60s 清理过期邮件 + 投递到期 webhook
const cronTimer = setInterval(() => {
void runCron(env, () => processDueDeliveries(env, resolveHost, 20, true))
.then((stats) => {
if (stats.expiredEmails || stats.orphanAttachments || stats.errors.length) {
console.log(JSON.stringify({ t: new Date().toISOString(), lvl: "info", msg: "cron", ...stats }));
}
})
.catch((err) => {
console.error(JSON.stringify({ t: new Date().toISOString(), lvl: "error", msg: "cron failed", error: String(err) }));
});
}, 60_000);
/** 优雅退出:停 cron、停服务、关库 */
function shutdown(signal: string) {
console.log(JSON.stringify({ t: new Date().toISOString(), lvl: "info", msg: "console shutting down", signal }));
clearInterval(cronTimer);
server.stop(true);
db.close();
process.exit(0);
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
async function mkdirSafe(dir: string) {
const { mkdir } = await import("node:fs/promises");
await mkdir(dir, { recursive: true });
}
+66
View File
@@ -0,0 +1,66 @@
/**
* LocalR2:基于文件系统的 R2 API 子集实现(仅本地开发/测试用)。
* key 直接映射为 root 下的相对路径(含子目录)。
*/
import { mkdir, rm } from "node:fs/promises";
import { existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import type { R2Bucket, R2ObjectBody } from "../env.ts";
/** # 成员变量: LocalR2 实例 **/
export class LocalR2 implements R2Bucket {
/** # 成员变量 **/
constructor(private root: string) {
mkdirSync(root, { recursive: true });
}
private resolve(key: string): string {
const safe = path.normalize(key).replace(/^(\.\.[/\\])+/, "");
return path.join(this.root, safe);
}
async put(
key: string,
value: ArrayBuffer | Uint8Array | string | ReadableStream | Blob,
): Promise<unknown> {
const file = this.resolve(key);
await mkdir(path.dirname(file), { recursive: true });
const data =
typeof value === "string"
? new TextEncoder().encode(value)
: value instanceof Uint8Array
? value
: value instanceof ArrayBuffer
? new Uint8Array(value)
: new Uint8Array(await new Response(value as ReadableStream).arrayBuffer());
await Bun.write(file, data);
return { key };
}
async get(key: string): Promise<R2ObjectBody | null> {
const file = this.resolve(key);
if (!existsSync(file)) return null;
const buf = await Bun.file(file).arrayBuffer();
return {
arrayBuffer: async () => buf,
text: async () => new TextDecoder().decode(buf),
json: async () => JSON.parse(new TextDecoder().decode(buf)),
};
}
async delete(keys: string | string[]): Promise<void> {
const list = Array.isArray(keys) ? keys : [keys];
for (const key of list) {
const file = this.resolve(key);
await rm(file, { force: true });
}
}
async head(key: string): Promise<{ key: string; size: number } | null> {
const file = this.resolve(key);
if (!existsSync(file)) return null;
const stat = await Bun.file(file).stat();
return { key, size: stat.size };
}
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Console 中间件:会话守卫、API/MCP Key 守卫、CSRF Origin 校验。
*/
import type { Env } from "./env.ts";
import type { AppVars } from "./env.ts";
import { readSessionCookie, verifySessionToken } from "./auth.ts";
import { keyHash } from "./db.ts";
import { nowIso } from "./util.ts";
import type { Context, Next } from "hono";
type Ctx = Context<{ Bindings: Env; Variables: AppVars }>;
/** # 函数
* 会话守卫:未登录返回 401。通过后 c.set("userId")
*/
export async function requireSession(c: Ctx, next: Next) {
const payload = await verifySessionToken(c.env, readSessionCookie(c.req.raw));
if (!payload) return c.json({ error: "unauthorized" }, 401);
c.set("userId", payload.uid);
await next();
}
/** # 函数
* CSRF 防护:对 cookie 会话的写请求校验 Origin/Referer 与 Host 一致。
* API Key 调用(无 cookie)不受影响。
*/
export async function csrfOriginCheck(c: Ctx, next: Next) {
const method = c.req.method.toUpperCase();
if (["GET", "HEAD", "OPTIONS"].includes(method)) return next();
const hasCookie = (c.req.header("Cookie") ?? "").includes("wmh_session=");
if (!hasCookie) return next();
const origin = c.req.header("Origin") ?? c.req.header("Referer");
if (!origin) return next(); // 同源非浏览器客户端
try {
const originHost = new URL(origin).host;
const targetHost = new URL(c.req.url).host;
if (originHost !== targetHost) return c.json({ error: "cross-origin request rejected" }, 403);
} catch {
return c.json({ error: "bad origin" }, 403);
}
await next();
}
/** # 函数
* API/MCP Key 守卫工厂:校验 Authorization: Bearer wmh_... 并限定 kind。
* 通过后 c.set("apiKeyKind"/"apiKeyId")。
* 参数: kind - 允许的凭据类型
*/
export function requireApiKey(kind: "api" | "mcp") {
return async (c: Ctx, next: Next) => {
const auth = c.req.header("Authorization") ?? "";
const m = /^Bearer\s+(.+)$/i.exec(auth);
const key = m?.[1]?.trim();
if (!key || !key.startsWith("wmh_")) {
return c.json({ error: "missing or invalid api key" }, 401);
}
const row = await c.env.DB.prepare(
"SELECT id, kind, revoked_at FROM api_credentials WHERE key_hash = ?",
)
.bind(await keyHash(key))
.first<{ id: string; kind: string; revoked_at: string | null }>();
if (!row || row.revoked_at) return c.json({ error: "invalid or revoked api key" }, 401);
if (row.kind !== kind) {
return c.json({ error: `this endpoint requires a ${kind} key` }, 403);
}
c.set("apiKeyKind", row.kind as "api" | "mcp");
c.set("apiKeyId", row.id);
// 记录最近使用时间(尽力而为,不阻塞请求;本地运行时无 ExecutionContext 时直接后台执行)
const touch = c.env.DB.prepare("UPDATE api_credentials SET last_used_at = ? WHERE id = ?")
.bind(nowIso(), row.id)
.run();
try {
c.executionCtx.waitUntil(touch);
} catch {
void touch;
}
await next();
};
}
/** # 函数
* 解析并校验 Bearer Key(返回凭据行);供 /api/v1 统一入口使用
*/
export async function resolveApiKey(
env: Env,
request: Request,
): Promise<{ ok: true; id: string; kind: "api" | "mcp" } | { ok: false; status: number; error: string }> {
const auth = request.headers.get("Authorization") ?? "";
const m = /^Bearer\s+(.+)$/i.exec(auth);
const key = m?.[1]?.trim();
if (!key || !key.startsWith("wmh_")) return { ok: false, status: 401, error: "missing or invalid api key" };
const row = await env.DB.prepare("SELECT id, kind, revoked_at FROM api_credentials WHERE key_hash = ?")
.bind(await keyHash(key))
.first<{ id: string; kind: string; revoked_at: string | null }>();
if (!row || row.revoked_at) return { ok: false, status: 401, error: "invalid or revoked api key" };
return { ok: true, id: row.id, kind: row.kind as "api" | "mcp" };
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Admin 路由:版本、Dashboard 统计、Cron 手动触发(本地开发/运维用)。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { runCron } from "../services/cleanup.ts";
import { processDueDeliveries } from "../services/webhook_delivery.ts";
import { resolveHost } from "../ssrf-resolver.ts";
import { APP_NAME, APP_VERSION } from "../version.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 函数
* 装配 admin 路由(会话保护)
*/
export function adminRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession);
/** 版本信息(来自 package.json) */
app.get("/version", (c) => c.json({ name: APP_NAME, version: APP_VERSION }));
/** Dashboard 统计 */
app.get("/stats", async (c) => {
const count = async (sql: string) =>
(await c.env.DB.prepare(sql).first<{ n: number }>())?.n ?? 0;
const now = nowIso();
const stats = {
nodes_online: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status = 'online'`),
nodes_total: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'`),
domains: await count("SELECT COUNT(*) AS n FROM domains"),
mailboxes: await count("SELECT COUNT(*) AS n FROM mailboxes"),
emails: await count(`SELECT COUNT(*) AS n FROM emails WHERE expires_at > '${now}'`),
emails_expired: await count(`SELECT COUNT(*) AS n FROM emails WHERE expires_at <= '${now}'`),
webhooks: await count("SELECT COUNT(*) AS n FROM webhooks"),
deliveries_pending: await count(
`SELECT COUNT(*) AS n FROM webhook_deliveries WHERE status IN ('pending','failed')`,
),
oauth_connections: await count("SELECT COUNT(*) AS n FROM oauth_connections"),
};
const recent = await c.env.DB.prepare(
`SELECT id, subject, from_address, received_at, size FROM emails
WHERE expires_at > ? ORDER BY received_at DESC LIMIT 8`,
)
.bind(now)
.all();
return c.json({ ...stats, recent_emails: recent.results });
});
/** 手动触发一次 Cron(TTL 清理 + webhook 投递) */
app.post("/cron/run", async (c) => {
const stats = await runCron(c.env, () =>
processDueDeliveries(c.env, resolveHost, 20, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1"),
);
return c.json({ ok: true, stats });
});
return app;
}
/** # 函数
* 当前 ISO 时间
*/
function nowIso(): string {
return new Date().toISOString();
}
+123
View File
@@ -0,0 +1,123 @@
/**
* /api/v1/* :对外 Console API(docs/07),Bearer API Key(wmh_)认证。
* 邮件读取强制检查未过期 + mailbox scope。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireApiKey } from "../middleware.ts";
import { upsertMailbox } from "../services/provision.ts";
import { nowIso, pageParams, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 函数
* 装配 /api/v1 路由
*/
export function apiV1Routes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireApiKey("api"));
/** 节点列表 */
app.get("/nodes", async (c) => {
const rows = await c.env.DB.prepare(
`SELECT id, name, cloudflare_account_id, status, version, last_seen_at, created_at
FROM nodes WHERE status != 'revoked' ORDER BY created_at DESC`,
).all();
return c.json({ items: rows.results });
});
/** 域名列表 */
app.get("/domains", async (c) => {
const rows = await c.env.DB.prepare(
"SELECT id, name, status, node_id, created_at FROM domains ORDER BY created_at DESC",
).all();
return c.json({ items: rows.results });
});
/** 邮箱列表 */
app.get("/mailboxes", async (c) => {
const rows = await c.env.DB.prepare(
`SELECT m.id, m.address, m.status, m.is_catch_all, m.retention_seconds, m.domain_id, d.name AS domain_name
FROM mailboxes m JOIN domains d ON d.id = m.domain_id ORDER BY m.created_at DESC`,
).all();
return c.json({ items: rows.results });
});
/** 创建邮箱 */
app.post("/mailboxes", async (c) => {
const body = await readJson<{ domain_id?: string; address?: string; retention_seconds?: number }>(c.req.raw);
if (!body.domain_id || !body.address) return c.json({ error: "domain_id and address required" }, 400);
const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
.bind(body.domain_id)
.first<{ id: string; name: string }>();
if (!domain) return c.json({ error: "domain not found" }, 404);
let address = body.address.trim().toLowerCase();
if (!address.includes("@")) address = `${address}@${domain.name}`;
else if (!address.endsWith(`@${domain.name}`)) return c.json({ error: `address must be @${domain.name}` }, 400);
const exists = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind(address).first();
if (exists) return c.json({ error: "mailbox already exists" }, 409);
const id = await upsertMailbox(c.env, {
domainId: body.domain_id,
address,
retentionSeconds: body.retention_seconds ? Number(body.retention_seconds) : undefined,
});
return c.json({ ok: true, id, address }, 201);
});
/** 删除邮箱 */
app.delete("/mailboxes/:id", async (c) => {
const res = await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(c.req.param("id")).run();
if (!res.meta?.changes) return c.json({ error: "not found" }, 404);
return c.json({ ok: true });
});
/** 邮件列表(仅未过期) */
app.get("/emails", async (c) => {
const url = new URL(c.req.url);
const { limit, offset } = pageParams(url);
const mailboxId = url.searchParams.get("mailbox_id");
const conds = ["e.expires_at > ?"];
const binds: unknown[] = [nowIso()];
if (mailboxId) {
conds.push("e.mailbox_id = ?");
binds.push(mailboxId);
}
const rows = await c.env.DB.prepare(
`SELECT e.id, e.mailbox_id, m.address AS mailbox_address, e.message_id, e.from_address, e.to_addresses,
e.subject, e.size, e.received_at, e.expires_at
FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id
WHERE ${conds.join(" AND ")} ORDER BY e.received_at DESC LIMIT ? OFFSET ?`,
);
const res = await rows.bind(...binds, limit, offset).all();
return c.json({ items: res.results });
});
/** 邮件详情(过期视为 410 Gone) */
app.get("/emails/:id", async (c) => {
const email = await c.env.DB.prepare(
`SELECT e.*, m.address AS mailbox_address FROM emails e
LEFT JOIN mailboxes m ON m.id = e.mailbox_id WHERE e.id = ?`,
)
.bind(c.req.param("id"))
.first<{ expires_at: string; html_body: string | null; text_body: string | null } & Record<string, unknown>>();
if (!email) return c.json({ error: "not found" }, 404);
if (email.expires_at <= nowIso()) return c.json({ error: "email expired" }, 410);
const atts = await c.env.DB.prepare(
"SELECT id, filename, content_type, size, content_id FROM email_attachments WHERE email_id = ?",
)
.bind(c.req.param("id"))
.all();
return c.json({ email, attachments: atts.results });
});
/** Webhook 列表 */
app.get("/webhooks", async (c) => {
const rows = await c.env.DB.prepare(
"SELECT id, name, endpoint, enabled, created_at FROM webhooks ORDER BY created_at DESC",
).all();
return c.json({ items: rows.results });
});
return app;
}
+152
View File
@@ -0,0 +1,152 @@
/**
* /api/internal/* :Node <-> Console 内部协议路由(docs/04)
*
* POST /api/internal/nodes/{node_id}/emails 邮件转发(message/rfc822)
* POST /api/internal/nodes/{node_id}/heartbeat 心跳
* GET /api/internal/nodes/{node_id}/config 配置拉取
* POST /api/internal/enroll bootstrap enrollment
*
* 前三者必须通过 HMAC 签名校验 + nonce 防重放 + credential 状态检查;
* enroll 用一次性 enrollment token(节点尚无 secret)。
*/
import { Hono } from "hono";
import type { Context, Next } from "hono";
import type { Env, AppVars } from "../env.ts";
import { verifyNodeRequest } from "../../../shared/src/index.ts";
import { ingestEmail, IngestError } from "../services/email_ingest.ts";
import { enrollNode } from "../services/provision.ts";
import { decryptSecret } from "../db.ts";
import { nowIso } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
type Ctx = Context<{ Bindings: Env; Variables: AppVars }>;
/** # 成员变量: nonce 防重放窗口(与 timestamp 窗口一致) **/
const NONCE_TTL_SECONDS = 600;
/** # 函数
* HMAC 校验中间件:验签、时间窗、body hash、nonce 唯一性、credential 状态。
* 原始请求体缓存到 c.var.rawBody 供路由处理器复用。
*/
async function nodeAuth(c: Ctx, next: Next) {
const nodeId = c.req.param("node_id");
const cred = await c.env.DB.prepare(
"SELECT id, secret_enc, status, revoked_at FROM node_credentials WHERE node_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
)
.bind(nodeId)
.first<{ id: string; secret_enc: string; status: string; revoked_at: string | null }>();
if (!cred) return c.json({ error: "unknown node or credential revoked" }, 401);
const raw = new Uint8Array(await c.req.arrayBuffer());
const secret = await decryptSecret(c.env, cred.secret_enc);
const verdict = await verifyNodeRequest({
secret,
method: c.req.method,
url: c.req.url,
body: raw,
headers: c.req.raw.headers,
});
if (!verdict.ok) return c.json({ error: verdict.error }, verdict.status);
// nonce 防重放:主键冲突即拒绝
try {
await c.env.DB.prepare("INSERT INTO nonces (nonce, node_id, expires_at) VALUES (?, ?, ?)")
.bind(
verdict.nonce,
nodeId,
new Date((verdict.timestamp + NONCE_TTL_SECONDS) * 1000).toISOString(),
)
.run();
} catch {
return c.json({ error: "nonce replay detected" }, 401);
}
// 凭据被撤销则在验签后仍然拒绝(撤销立即生效)
if (cred.revoked_at) return c.json({ error: "credential revoked" }, 401);
c.set("rawBody", raw);
c.set("nodeCredentialId", cred.id);
await next();
}
/** # 函数
* 装配 internal 路由
*/
export function internalRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
/** Enrollment:enrollment token 换取 Node 身份(凭证明文仅返回一次) */
app.post("/enroll", async (c) => {
let token = "";
try {
const body = (await c.req.json()) as { enrollment_token?: string };
token = body.enrollment_token ?? "";
} catch {
return c.json({ error: "invalid body" }, 400);
}
if (!token.startsWith("wenr_")) return c.json({ error: "invalid enrollment token" }, 401);
const result = await enrollNode(c.env, token);
if (!result) return c.json({ error: "invalid or expired enrollment token" }, 401);
return c.json({
ok: true,
node_id: result.node_id,
credential_id: result.credential_id,
credential_secret: result.credential_secret,
ingress_secret: result.ingress_secret,
});
});
/** 邮件转发入口:message/rfc822 原文入库 */
app.post("/nodes/:node_id/emails", nodeAuth, async (c) => {
// nodeAuth 已确保节点存在,param 必有值
const nodeId = c.req.param("node_id")!;
const raw = c.get("rawBody") ?? new Uint8Array();
const mailFrom = c.req.header("X-WmailHub-Mail-From") ?? "";
const rcptTo = c.req.header("X-WmailHub-Rcpt-To") ?? "";
try {
const result = await ingestEmail(c.env, { raw, mailFrom, rcptTo, nodeId });
// 更新节点可见性
await c.env.DB.prepare("UPDATE nodes SET last_seen_at = ?, updated_at = ? WHERE id = ?")
.bind(nowIso(), nowIso(), nodeId)
.run();
return c.json(
{ ok: true, id: result.id, mailbox_id: result.mailboxId, expires_at: result.expiresAt },
202,
);
} catch (err) {
if (err instanceof IngestError) return c.json({ error: err.message }, err.status as 413);
return c.json({ error: err instanceof Error ? err.message : "ingest failed" }, 500);
}
});
/** 心跳:更新 last_seen_at / version / status=online */
app.post("/nodes/:node_id/heartbeat", nodeAuth, async (c) => {
const nodeId = c.req.param("node_id");
let version: string | null = null;
try {
const body = c.get("rawBody") ?? new Uint8Array();
const parsed = JSON.parse(new TextDecoder().decode(body)) as { version?: string };
version = parsed.version ?? null;
} catch {
// 空 body 合法
}
await c.env.DB.prepare(
"UPDATE nodes SET last_seen_at = ?, status = 'online', version = COALESCE(?, version), updated_at = ? WHERE id = ?",
)
.bind(nowIso(), version, nowIso(), nodeId)
.run();
return c.json({ ok: true });
});
/** 配置拉取:协议版本与默认保留时长 */
app.get("/nodes/:node_id/config", nodeAuth, (c) => {
return c.json({
protocol_version: 1,
default_retention_seconds: 86_400,
server_time: nowIso(),
});
});
return app;
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Console API / MCP 凭据管理路由。
* Key 规范:wmh_ 开头 + 随机串,总长 32;库中只存 SHA-256,明文仅创建时返回一次。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { generateWmailKey, isValidWmailKey } from "../../../shared/src/index.ts";
import { keyHash } from "../db.ts";
import { ids } from "../../../shared/src/index.ts";
import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 函数
* 装配 API/MCP Key 管理路由(会话保护)
*/
export function keyRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession);
/** 列表(仅前 4 位尾缀,不返回明文) */
app.get("/", async (c) => {
const rows = await c.env.DB.prepare(
`SELECT id, kind, name, key_last4, created_at, revoked_at, last_used_at
FROM api_credentials ORDER BY created_at DESC`,
).all();
return c.json({ items: rows.results });
});
/** 生成新 Key(kind = api | mcp) */
app.post("/", async (c) => {
const body = await readJson<{ kind?: string; name?: string }>(c.req.raw);
const kind = body.kind === "mcp" ? "mcp" : body.kind === "api" ? "api" : null;
if (!kind) return c.json({ error: "kind must be api or mcp" }, 400);
const key = generateWmailKey();
if (!isValidWmailKey(key)) return c.json({ error: "key generator broken" }, 500);
const id = ids.apiKey();
await c.env.DB.prepare(
"INSERT INTO api_credentials (id, kind, name, key_hash, key_last4, created_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(id, kind, (body.name ?? "").trim() || (kind === "api" ? "Console API Key" : "Console MCP Key"), await keyHash(key), key.slice(-4), nowIso())
.run();
return c.json({ ok: true, id, kind, key }, 201);
});
/** 撤销 Key */
app.post("/:id/revoke", async (c) => {
const id = c.req.param("id");
const res = await c.env.DB.prepare("UPDATE api_credentials SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL")
.bind(nowIso(), id)
.run();
if (!res.meta?.changes) return c.json({ error: "not found or already revoked" }, 404);
return c.json({ ok: true });
});
/** 彻底删除 Key 记录 */
app.delete("/:id", async (c) => {
const id = c.req.param("id");
await c.env.DB.prepare("DELETE FROM api_credentials WHERE id = ?").bind(id).run();
return c.json({ ok: true });
});
return app;
}
+251
View File
@@ -0,0 +1,251 @@
/**
* /api/v1/mcp :MCP over Streamable HTTP(JSON-RPC 2.0),MCP Key(wmh_)认证。
*
* 工具:list_mailboxes / list_emails / get_email / create_mailbox / delete_mailbox / list_webhooks
* 权限:基于 Console credential(docs/09 Phase 9);邮件强制检查未过期。
* 实现说明:POST JSON-RPC -> application/json 响应(Streamable HTTP 允许服务器
* 返回普通 JSON);GET 返回 405(无 SSE 流)。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireApiKey } from "../middleware.ts";
import { upsertMailbox } from "../services/provision.ts";
import { APP_VERSION } from "../version.ts";
import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 成员变量: MCP 协议版本 **/
const MCP_PROTOCOL_VERSION = "2025-06-18";
/** # 成员变量: MCP 工具定义(JSON Schema) **/
const TOOLS = [
{
name: "list_mailboxes",
description: "列出所有邮箱(含域名、catch-all 标记、保留秒数)",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{
name: "list_emails",
description: "列出邮件(仅未过期),可按 mailbox_id 过滤",
inputSchema: {
type: "object",
properties: {
mailbox_id: { type: "string", description: "可选,按邮箱过滤" },
limit: { type: "number", description: "可选,默认 20,最大 100" },
},
additionalProperties: false,
},
},
{
name: "get_email",
description: "获取单封邮件详情(正文与附件元数据);过期邮件返回 410 语义错误",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "邮件 ID(msg_...)" } },
required: ["id"],
additionalProperties: false,
},
},
{
name: "create_mailbox",
description: "在指定域名下创建邮箱",
inputSchema: {
type: "object",
properties: {
domain_id: { type: "string", description: "域名 ID(dom_...)" },
address: { type: "string", description: "邮箱地址或本地部分" },
retention_seconds: { type: "number", description: "可选保留秒数,默认 86400(1 天)" },
},
required: ["domain_id", "address"],
additionalProperties: false,
},
},
{
name: "delete_mailbox",
description: "删除邮箱",
inputSchema: {
type: "object",
properties: { id: { type: "string", description: "邮箱 ID(mbx_...)" } },
required: ["id"],
additionalProperties: false,
},
},
{
name: "list_webhooks",
description: "列出 Webhook(不含 secret)",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
] as const;
/** # 函数
* 装配 MCP 路由
*/
export function mcpRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireApiKey("mcp"));
app.post("/", async (c) => {
const rpc = await readJson<JsonRpcRequest>(c.req.raw);
if (typeof rpc.jsonrpc !== "string" || typeof rpc.method !== "string") {
return c.json(jsonRpcError(null, -32600, "invalid request"), 400);
}
const id = rpc.id ?? null;
try {
switch (rpc.method) {
case "initialize":
return c.json({
jsonrpc: "2.0",
id,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: "wmailhub", version: APP_VERSION },
},
});
case "notifications/initialized":
return new Response(null, { status: 202 });
case "ping":
return c.json({ jsonrpc: "2.0", id, result: {} });
case "tools/list":
return c.json({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
case "tools/call": {
const params = (rpc.params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
const result = await callTool(c.env, params.name ?? "", params.arguments ?? {});
return c.json({ jsonrpc: "2.0", id, result });
}
default:
return c.json(jsonRpcError(id, -32601, `method not found: ${rpc.method}`), 200);
}
} catch (err) {
const message = err instanceof McpToolError ? err.message : `internal error: ${err instanceof Error ? err.message : String(err)}`;
const code = err instanceof McpToolError ? err.rpcCode : -32603;
return c.json(jsonRpcError(id, code, message), 200);
}
});
app.get("/", (c) => c.json({ error: "POST JSON-RPC requests only" }, 405));
return app;
}
/** # 成员变量: JSON-RPC 请求结构 **/
interface JsonRpcRequest {
jsonrpc: string;
id?: string | number | null;
method: string;
params?: Record<string, unknown>;
}
/** # 函数
* 构造 JSON-RPC error 响应
*/
function jsonRpcError(id: string | number | null, code: number, message: string) {
return { jsonrpc: "2.0", id, error: { code, message } };
}
/** # 成员变量: 工具层错误(映射到 JSON-RPC error code) **/
class McpToolError extends Error {
constructor(
message: string,
public rpcCode: number,
) {
super(message);
}
}
/** # 函数
* 工具调用分发
*/
async function callTool(
env: Env,
name: string,
args: Record<string, unknown>,
): Promise<{ content: { type: string; text: string }[]; isError?: boolean }> {
const data = await dispatchTool(env, name, args);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
async function dispatchTool(env: Env, name: string, args: Record<string, unknown>): Promise<unknown> {
switch (name) {
case "list_mailboxes": {
const rows = await env.DB.prepare(
`SELECT m.id, m.address, m.status, m.is_catch_all, m.retention_seconds, d.name AS domain_name
FROM mailboxes m JOIN domains d ON d.id = m.domain_id ORDER BY m.created_at DESC`,
).all();
return { mailboxes: rows.results };
}
case "list_emails": {
const limit = Math.min(Math.max(Number(args.limit) || 20, 1), 100);
const mailboxId = typeof args.mailbox_id === "string" ? args.mailbox_id : null;
const conds = ["e.expires_at > ?"];
const binds: unknown[] = [nowIso()];
if (mailboxId) {
conds.push("e.mailbox_id = ?");
binds.push(mailboxId);
}
const rows = await env.DB.prepare(
`SELECT e.id, e.mailbox_id, m.address AS mailbox_address, e.from_address, e.subject,
e.size, e.received_at, e.expires_at
FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id
WHERE ${conds.join(" AND ")} ORDER BY e.received_at DESC LIMIT ?`,
)
.bind(...binds, limit)
.all();
return { emails: rows.results };
}
case "get_email": {
const id = String(args.id ?? "");
if (!id) throw new McpToolError("id required", -32602);
const email = await env.DB.prepare(
`SELECT e.*, m.address AS mailbox_address FROM emails e
LEFT JOIN mailboxes m ON m.id = e.mailbox_id WHERE e.id = ?`,
)
.bind(id)
.first();
if (!email) throw new McpToolError("email not found", -32002);
if ((email as { expires_at: string }).expires_at <= nowIso()) {
throw new McpToolError("email expired", -32002);
}
const atts = await env.DB.prepare(
"SELECT id, filename, content_type, size, content_id FROM email_attachments WHERE email_id = ?",
)
.bind(id)
.all();
return { email, attachments: atts.results };
}
case "create_mailbox": {
const domainId = String(args.domain_id ?? "");
const address = String(args.address ?? "").trim().toLowerCase();
if (!domainId || !address) throw new McpToolError("domain_id and address required", -32602);
const domain = await env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
.bind(domainId)
.first<{ id: string; name: string }>();
if (!domain) throw new McpToolError("domain not found", -32002);
let full = address;
if (!full.includes("@")) full = `${full}@${domain.name}`;
else if (!full.endsWith(`@${domain.name}`)) throw new McpToolError(`address must be @${domain.name}`, -32602);
const exists = await env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind(full).first();
if (exists) throw new McpToolError("mailbox already exists", -32002);
const retention = args.retention_seconds ? Number(args.retention_seconds) : undefined;
const id = await upsertMailbox(env, { domainId, address: full, retentionSeconds: retention });
return { id, address: full };
}
case "delete_mailbox": {
const id = String(args.id ?? "");
if (!id) throw new McpToolError("id required", -32602);
const res = await env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
if (!res.meta?.changes) throw new McpToolError("mailbox not found", -32002);
return { ok: true };
}
case "list_webhooks": {
const rows = await env.DB.prepare(
"SELECT id, name, endpoint, enabled, created_at FROM webhooks ORDER BY created_at DESC",
).all();
return { webhooks: rows.results };
}
default:
throw new McpToolError(`unknown tool: ${name}`, -32601);
}
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Cloudflare OAuth 路由(docs/02):
* GET /api/oauth/cloudflare/start?node_id= 生成 state,返回授权跳转 URL
* GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动安装
* POST /api/oauth/cloudflare/sim/approve sim 模式模拟用户授权(仅本地)
* GET /api/oauth/connections 已建立的 OAuth 连接
*
* 安全:state 一次性(用后即删)、5 分钟过期、绑定 node_id;token AES-GCM 加密入库,
* 不进前端与日志;Account ownership 以 token 实际可访问的 accounts 为准。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { aesEncrypt, deriveAesKey, ids } from "../../../shared/src/index.ts";
import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi } from "../services/cfapi.ts";
import { provisionNode } from "../services/provision.ts";
import { getSetting } from "../db.ts";
import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 成员变量: OAuth state 有效期(秒) **/
const STATE_TTL_SECONDS = 300;
/** # 函数
* 是否使用真实 Cloudflare OAuth
*/
export function isRealOAuth(env: Env): boolean {
return Boolean(env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET && !env.WMAILHUB_SIM_CF);
}
/** # 函数
* 装配 OAuth 路由
*/
export function oauthRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession);
/** 授权模式与入口信息(前端 Add Node 向导用) */
app.get("/mode", (c) => {
return c.json({
mode: isRealOAuth(c.env) ? "cloudflare" : "sim",
authorize_url_base: isRealOAuth(c.env) ? CF_OAUTH_AUTHORIZE_URL : null,
});
});
/** 开始授权:创建 state 并返回跳转 URL */
app.get("/cloudflare/start", async (c) => {
const nodeId = new URL(c.req.url).searchParams.get("node_id") ?? "";
const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status = 'pending_oauth' OR id = ?")
.bind(nodeId, nodeId)
.first<{ id: string }>();
if (!node) return c.json({ error: "node not found" }, 404);
const state = "cfstate_" + ids.oauthState();
await c.env.DB.prepare(
"INSERT INTO oauth_states (state, node_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
)
.bind(state, nodeId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
.run();
if (isRealOAuth(c.env)) {
const base = c.env.PUBLIC_BASE_URL ?? new URL(c.req.url).origin;
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
const url =
`${CF_OAUTH_AUTHORIZE_URL}?response_type=code` +
`&client_id=${encodeURIComponent(c.env.CF_OAUTH_CLIENT_ID!)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` +
`&state=${encodeURIComponent(state)}`;
return c.json({ url, state });
}
// sim 模式:前端内置模拟授权页
return c.json({ url: `/oauth/sim/authorize?state=${encodeURIComponent(state)}`, state });
});
/** 授权回调(浏览器重垒):校验 state -> 兑换 token -> 自动安装 -> 跳回控制台 */
app.get("/cloudflare/callback", async (c) => {
const url = new URL(c.req.url);
const code = url.searchParams.get("code") ?? "";
const state = url.searchParams.get("state") ?? "";
const verdict = await consumeState(c.env, state);
if (!verdict) {
return c.text("OAuth state invalid or expired (possible CSRF)", 400);
}
const cf = getCFApi(c.env, c.env.DB);
try {
const base = c.env.PUBLIC_BASE_URL ?? url.origin;
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
const token = await cf.exchangeToken(code, redirectUri);
// 加密入库(不进前端/日志)
await saveConnection(c.env, token.accessToken, token.expiresIn);
const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken });
const target = `/nodes?provision=${encodeURIComponent(verdict.nodeId)}&ok=${result.ok ? 1 : 0}`;
return c.redirect(target, 302);
} catch (err) {
return c.text(`OAuth provisioning failed: ${err instanceof Error ? err.message : String(err)}`, 500);
}
});
/** sim 模式:模拟授权页的确认动作(本地开发用,需会话) */
app.post("/cloudflare/sim/approve", async (c) => {
const body = await readJson<{ state?: string }>(c.req.raw);
const verdict = await consumeState(c.env, body.state ?? "");
if (!verdict) return c.json({ error: "state invalid or expired" }, 400);
const cf = getCFApi(c.env, c.env.DB);
const token = await cf.exchangeToken("sim-code", "urn:unused");
await saveConnection(c.env, token.accessToken, token.expiresIn);
const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken });
return c.json({ ok: result.ok, error: result.error, node_id: verdict.nodeId });
});
/** 已有连接列表(不含 token) */
app.get("/connections", async (c) => {
const rows = await c.env.DB.prepare(
"SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC",
).all();
return c.json({ items: rows.results });
});
return app;
}
/** # 函数
* 消费一次性 state:存在且未过期则删除并返回 node_id,否则 null(CSRF 防护核心)
*/
async function consumeState(
env: Env,
state: string,
): Promise<{ nodeId: string } | null> {
if (!state) return null;
const row = await env.DB.prepare("SELECT node_id, expires_at FROM oauth_states WHERE state = ?")
.bind(state)
.first<{ node_id: string; expires_at: string }>();
if (!row) return null;
await env.DB.prepare("DELETE FROM oauth_states WHERE state = ?").bind(state).run();
if (row.expires_at < nowIso()) return null;
return { nodeId: row.node_id };
}
/** # 函数
* OAuth access token 加密入库
*/
async function saveConnection(env: Env, accessToken: string, expiresIn: number): Promise<void> {
const key = await deriveAesKey(await getSetting(env, "app_secret") ?? "wmailhub");
const encrypted = await aesEncrypt(key, accessToken);
await env.DB.prepare(
`INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at)
VALUES (?, 'cloudflare', NULL, ?, NULL, ?, ?, ?)`,
)
.bind(ids.oauth(), encrypted, new Date(Date.now() + expiresIn * 1000).toISOString(), nowIso(), nowIso())
.run();
}
+362
View File
@@ -0,0 +1,362 @@
/**
* Console 管理路由(Node / Domain / Mailbox / Email)。
* 全部要求会话登录。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { createNode, emailWorkerNameFor, loadSteps } from "../services/provision.ts";
import { upsertMailbox, upsertDomain } from "../services/provision.ts";
import { decryptSecret } from "../db.ts";
import { ids } from "../../../shared/src/index.ts";
import { checkWebhookUrl } from "../../../shared/src/index.ts";
import { nowIso, pageParams, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 函数
* 装配 Node / Domain / Mailbox / Email 管理路由
*/
export function resourceRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession);
installNodeRoutes(app);
installDomainRoutes(app);
installMailboxRoutes(app);
installEmailRoutes(app);
return app;
}
/** # 函数
* Node CRUD 与安装状态
*/
function installNodeRoutes(app: App) {
/** Node 列表(不含任何 secret) */
app.get("/nodes", async (c) => {
const { limit, offset } = pageParams(new URL(c.req.url));
const rows = await c.env.DB.prepare(
`SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at,
ingress_url, created_at, updated_at
FROM nodes WHERE status != 'revoked' ORDER BY created_at DESC LIMIT ? OFFSET ?`,
)
.bind(limit, offset)
.all();
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'").first<{ n: number }>();
return c.json({ items: rows.results, total: total?.n ?? 0 });
});
/** Add Node:创建节点,返回 enrollment token(仅此一次展示)与 OAuth 授权入口信息 */
app.post("/nodes", async (c) => {
const body = await readJson<{ name?: string; ingress_url?: string }>(c.req.raw);
const name = (body.name ?? "").trim();
if (!name) return c.json({ error: "name required" }, 400);
const ingressUrl = (body.ingress_url ?? "http://127.0.0.1:8788/ingress/email").trim();
try {
new URL(ingressUrl);
} catch {
return c.json({ error: "invalid ingress_url" }, 400);
}
const created = await createNode(c.env, { name, ingressUrl });
return c.json({
ok: true,
node_id: created.nodeId,
enrollment_token: created.enrollmentToken,
ingress_secret: created.ingressSecret,
worker_name: emailWorkerNameFor(created.nodeId),
}, 201);
});
/** Node 详情:含安装步骤状态与 OAuth 连接状态 */
app.get("/nodes/:id", async (c) => {
const id = c.req.param("id");
const node = await c.env.DB.prepare(
`SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at,
ingress_url, enrollment_expires_at, created_at, updated_at
FROM nodes WHERE id = ?`,
)
.bind(id)
.first();
if (!node) return c.json({ error: "not found" }, 404);
const steps = await loadSteps(c.env, id);
const oauth = await c.env.DB.prepare(
"SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC LIMIT 1",
).first();
const domains = await c.env.DB.prepare("SELECT id, name, status, worker_id FROM domains WHERE node_id = ?")
.bind(id)
.all();
return c.json({ node, steps, oauth: oauth ?? null, domains: domains.results });
});
/** 删除 Node(软删:撤销 credential + 标记 revoked) */
app.delete("/nodes/:id", async (c) => {
const id = c.req.param("id");
await c.env.DB.prepare(
"UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'",
)
.bind(nowIso(), id)
.run();
await c.env.DB.prepare("UPDATE nodes SET status = 'revoked', updated_at = ? WHERE id = ?")
.bind(nowIso(), id)
.run();
return c.json({ ok: true });
});
/** 重新生成 enrollment token(credential 轮换入口) */
app.post("/nodes/:id/rotate", async (c) => {
const id = c.req.param("id");
const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
.bind(id)
.first();
if (!node) return c.json({ error: "not found" }, 404);
const token = "wenr_" + crypto.randomUUID().replace(/-/g, "");
await c.env.DB.prepare(
"UPDATE nodes SET enrollment_token = ?, enrollment_expires_at = ?, updated_at = ? WHERE id = ?",
)
.bind(token, new Date(Date.now() + 24 * 3600 * 1000).toISOString(), nowIso(), id)
.run();
return c.json({ ok: true, enrollment_token: token });
});
/** 本地/运维用途:查看 ingress secret(会话保护;真实部署由 provisioning 自动注入 Worker) */
app.get("/nodes/:id/ingress-secret", async (c) => {
const id = c.req.param("id");
const node = await c.env.DB.prepare("SELECT ingress_secret_enc FROM nodes WHERE id = ?")
.bind(id)
.first<{ ingress_secret_enc: string }>();
if (!node) return c.json({ error: "not found" }, 404);
return c.json({ ingress_secret: await decryptSecret(c.env, node.ingress_secret_enc) });
});
}
/** # 函数
* Domain CRUD
*/
function installDomainRoutes(app: App) {
app.get("/domains", async (c) => {
const { limit, offset } = pageParams(new URL(c.req.url));
const rows = await c.env.DB.prepare(
`SELECT d.*, n.name AS node_name,
(SELECT COUNT(*) FROM mailboxes m WHERE m.domain_id = d.id) AS mailbox_count
FROM domains d LEFT JOIN nodes n ON n.id = d.node_id
ORDER BY d.created_at DESC LIMIT ? OFFSET ?`,
)
.bind(limit, offset)
.all();
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM domains").first<{ n: number }>();
return c.json({ items: rows.results, total: total?.n ?? 0 });
});
/** 手工添加 Domain(供无 OAuth 场景;OAuth 安装自动创建) */
app.post("/domains", async (c) => {
const body = await readJson<{ name?: string; node_id?: string; cloudflare_account_id?: string }>(c.req.raw);
const name = (body.name ?? "").trim().toLowerCase();
if (!name || !name.includes(".")) return c.json({ error: "invalid domain name" }, 400);
const id = await upsertDomain(c.env, {
cloudflareAccountId: body.cloudflare_account_id ?? "manual",
nodeId: body.node_id ?? null as unknown as string,
name,
zoneId: "manual",
workerId: null as unknown as string,
});
return c.json({ ok: true, id }, 201);
});
app.delete("/domains/:id", async (c) => {
const id = c.req.param("id");
const used = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes WHERE domain_id = ?")
.bind(id)
.first<{ n: number }>();
if ((used?.n ?? 0) > 0) return c.json({ error: "domain has mailboxes" }, 409);
await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run();
return c.json({ ok: true });
});
}
/** # 函数
* Mailbox CRUD
*/
function installMailboxRoutes(app: App) {
app.get("/mailboxes", async (c) => {
const { limit, offset } = pageParams(new URL(c.req.url));
const domainId = new URL(c.req.url).searchParams.get("domain_id");
const where = domainId ? "WHERE m.domain_id = ?" : "";
const rows = await c.env.DB.prepare(
`SELECT m.*, d.name AS domain_name,
(SELECT COUNT(*) FROM emails e WHERE e.mailbox_id = m.id) AS email_count
FROM mailboxes m JOIN domains d ON d.id = m.domain_id
${where} ORDER BY m.created_at DESC LIMIT ? OFFSET ?`,
);
const bound = domainId ? rows.bind(domainId, limit, offset) : rows.bind(limit, offset);
const res = await bound.all();
const totalRows = domainId
? await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes WHERE domain_id = ?").bind(domainId).first<{ n: number }>()
: await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes").first<{ n: number }>();
return c.json({ items: res.results, total: totalRows?.n ?? 0 });
});
app.post("/mailboxes", async (c) => {
const body = await readJson<{ domain_id?: string; address?: string; retention_seconds?: number; catch_all?: boolean }>(c.req.raw);
if (!body.domain_id || !body.address) return c.json({ error: "domain_id and address required" }, 400);
const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
.bind(body.domain_id)
.first<{ id: string; name: string }>();
if (!domain) return c.json({ error: "domain not found" }, 404);
let address = body.address.trim().toLowerCase();
if (body.catch_all) address = `*@${domain.name}`;
else if (!address.includes("@")) address = `${address}@${domain.name}`;
else if (!address.endsWith(`@${domain.name}`)) return c.json({ error: `address must be @${domain.name}` }, 400);
const exists = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind(address).first();
if (exists) return c.json({ error: "mailbox already exists" }, 409);
const id = await upsertMailbox(c.env, {
domainId: body.domain_id,
address,
isCatchAll: Boolean(body.catch_all),
retentionSeconds: body.retention_seconds ? Number(body.retention_seconds) : undefined,
});
return c.json({ ok: true, id, address }, 201);
});
app.patch("/mailboxes/:id", async (c) => {
const id = c.req.param("id");
const body = await readJson<{ status?: string; retention_seconds?: number | null }>(c.req.raw);
if (body.status && !["active", "disabled"].includes(body.status)) {
return c.json({ error: "invalid status" }, 400);
}
const mailbox = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE id = ?").bind(id).first();
if (!mailbox) return c.json({ error: "not found" }, 404);
if (body.status) {
await c.env.DB.prepare("UPDATE mailboxes SET status = ?, updated_at = ? WHERE id = ?")
.bind(body.status, nowIso(), id)
.run();
}
if (body.retention_seconds !== undefined) {
await c.env.DB.prepare("UPDATE mailboxes SET retention_seconds = ?, updated_at = ? WHERE id = ?")
.bind(body.retention_seconds ? Number(body.retention_seconds) : null, nowIso(), id)
.run();
}
return c.json({ ok: true });
});
app.delete("/mailboxes/:id", async (c) => {
const id = c.req.param("id");
await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
return c.json({ ok: true });
});
}
/** # 函数
* Email 查询 / 详情 / 附件 / 手动删除
*/
function installEmailRoutes(app: App) {
app.get("/emails", async (c) => {
const url = new URL(c.req.url);
const { limit, offset } = pageParams(url);
const mailboxId = url.searchParams.get("mailbox_id");
const q = url.searchParams.get("q");
const conds: string[] = [];
const binds: unknown[] = [];
if (mailboxId) {
conds.push("mailbox_id = ?");
binds.push(mailboxId);
}
if (q) {
conds.push("(subject LIKE ? OR from_address LIKE ?)");
binds.push(`%${q}%`, `%${q}%`);
}
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
const rows = await c.env.DB.prepare(
`SELECT e.id, e.mailbox_id, e.message_id, e.from_address, e.to_addresses, e.subject, e.size,
e.received_at, e.expires_at, m.address AS mailbox_address,
(SELECT COUNT(*) FROM email_attachments a WHERE a.email_id = e.id) AS attachment_count
FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id
${where} ORDER BY e.received_at DESC LIMIT ? OFFSET ?`,
);
const res = await (binds.length ? rows.bind(...binds, limit, offset) : rows.bind(limit, offset)).all();
const totalRows = binds.length
? await c.env.DB.prepare(`SELECT COUNT(*) AS n FROM emails ${where}`).bind(...binds).first<{ n: number }>()
: await c.env.DB.prepare("SELECT COUNT(*) AS n FROM emails").first<{ n: number }>();
return c.json({ items: res.results, total: totalRows?.n ?? 0 });
});
app.get("/emails/:id", async (c) => {
const id = c.req.param("id");
const email = await c.env.DB.prepare(
`SELECT e.*, m.address AS mailbox_address FROM emails e
LEFT JOIN mailboxes m ON m.id = e.mailbox_id WHERE e.id = ?`,
)
.bind(id)
.first();
if (!email) return c.json({ error: "not found" }, 404);
const atts = await c.env.DB.prepare(
"SELECT id, filename, content_type, size, content_id FROM email_attachments WHERE email_id = ?",
)
.bind(id)
.all();
return c.json({ email, attachments: atts.results });
});
/** 附件下载 */
app.get("/emails/:id/attachments/:attId", async (c) => {
const attId = c.req.param("attId");
const att = await c.env.DB.prepare("SELECT filename, content_type, storage_key FROM email_attachments WHERE id = ? AND email_id = ?")
.bind(attId, c.req.param("id"))
.first<{ filename: string; content_type: string; storage_key: string }>();
if (!att) return c.json({ error: "not found" }, 404);
const obj = await c.env.R2.get(att.storage_key);
if (!obj) return c.json({ error: "object missing" }, 404);
const buf = await obj.arrayBuffer();
return new Response(buf, {
headers: {
"Content-Type": att.content_type || "application/octet-stream",
"Content-Disposition": `attachment; filename="${encodeURIComponent(att.filename)}"`,
},
});
});
/** raw MIME 下载(.eml) */
app.get("/emails/:id/raw", async (c) => {
const email = await c.env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?")
.bind(c.req.param("id"))
.first<{ raw_storage_key: string }>();
if (!email) return c.json({ error: "not found" }, 404);
const obj = await c.env.R2.get(email.raw_storage_key);
if (!obj) return c.json({ error: "object missing" }, 404);
return new Response(await obj.arrayBuffer(), {
headers: {
"Content-Type": "message/rfc822",
"Content-Disposition": `attachment; filename="${c.req.param("id")}.eml"`,
},
});
});
app.delete("/emails/:id", async (c) => {
const id = c.req.param("id");
const email = await c.env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?")
.bind(id)
.first<{ raw_storage_key: string }>();
if (!email) return c.json({ error: "not found" }, 404);
const atts = await c.env.DB.prepare("SELECT storage_key FROM email_attachments WHERE email_id = ?")
.bind(id)
.all<{ storage_key: string | null }>();
const keys = [email.raw_storage_key, ...atts.results.map((a) => a.storage_key)].filter(Boolean) as string[];
if (keys.length) await c.env.R2.delete(keys);
await c.env.DB.prepare("DELETE FROM email_attachments WHERE email_id = ?").bind(id).run();
await c.env.DB.prepare("DELETE FROM webhook_deliveries WHERE email_id = ?").bind(id).run();
await c.env.DB.prepare("DELETE FROM emails WHERE id = ?").bind(id).run();
return c.json({ ok: true });
});
}
/** # 函数
* webhook endpoint 的基本格式校验(创建时调用)
*/
export function validateEndpoint(endpoint: string): string | null {
try {
new URL(endpoint);
} catch {
return "invalid url";
}
return checkWebhookUrl(endpoint);
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Console Webhook 管理路由(docs/07):CRUD、secret、投递日志、手动测试/重投。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { ids, checkWebhookUrl } from "../../../shared/src/index.ts";
import { attemptDelivery, buildWebhookPayload } from "../services/webhook_delivery.ts";
import { pageParams, readJson, nowIso } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 函数
* 装配 Webhook 管理路由(会话保护)
*/
export function webhookRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession);
/** 列表(不含 secret 明文) */
app.get("/", async (c) => {
const { limit, offset } = pageParams(new URL(c.req.url));
const rows = await c.env.DB.prepare(
`SELECT w.id, w.name, w.endpoint, w.enabled, w.created_at, w.updated_at,
(SELECT COUNT(*) FROM webhook_deliveries d WHERE d.webhook_id = w.id) AS delivery_count,
(SELECT COUNT(*) FROM webhook_deliveries d WHERE d.webhook_id = w.id AND d.status = 'success') AS success_count
FROM webhooks w ORDER BY w.created_at DESC LIMIT ? OFFSET ?`,
)
.bind(limit, offset)
.all();
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM webhooks").first<{ n: number }>();
return c.json({ items: rows.results, total: total?.n ?? 0 });
});
/** 创建:secret 生成后明文仅返回一次 */
app.post("/", async (c) => {
const body = await readJson<{ name?: string; endpoint?: string }>(c.req.raw);
const name = (body.name ?? "").trim();
const endpoint = (body.endpoint ?? "").trim();
if (!name || !endpoint) return c.json({ error: "name and endpoint required" }, 400);
const unsafe = checkWebhookUrl(endpoint, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
if (unsafe) return c.json({ error: `endpoint rejected: ${unsafe}` }, 400);
const id = ids.webhook();
const secret = "whsec_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
const now = nowIso();
await c.env.DB.prepare(
"INSERT INTO webhooks (id, name, endpoint, secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)",
)
.bind(id, name, endpoint, secret, now, now)
.run();
return c.json({ ok: true, id, secret }, 201);
});
/** 更新(name/endpoint/enabled);endpoint 变更时重新过 SSRF 校验 */
app.patch("/:id", async (c) => {
const id = c.req.param("id");
const body = await readJson<{ name?: string; endpoint?: string; enabled?: boolean }>(c.req.raw);
const hook = await c.env.DB.prepare("SELECT id FROM webhooks WHERE id = ?").bind(id).first();
if (!hook) return c.json({ error: "not found" }, 404);
if (body.endpoint !== undefined) {
const unsafe = checkWebhookUrl(body.endpoint, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
if (unsafe) return c.json({ error: `endpoint rejected: ${unsafe}` }, 400);
await c.env.DB.prepare("UPDATE webhooks SET endpoint = ?, updated_at = ? WHERE id = ?")
.bind(body.endpoint, nowIso(), id)
.run();
}
if (body.name !== undefined) {
await c.env.DB.prepare("UPDATE webhooks SET name = ?, updated_at = ? WHERE id = ?")
.bind(body.name, nowIso(), id)
.run();
}
if (body.enabled !== undefined) {
await c.env.DB.prepare("UPDATE webhooks SET enabled = ?, updated_at = ? WHERE id = ?")
.bind(body.enabled ? 1 : 0, nowIso(), id)
.run();
}
return c.json({ ok: true });
});
/** 轮换 secret(明文仅返回一次) */
app.post("/:id/rotate", async (c) => {
const id = c.req.param("id");
const secret = "whsec_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
const res = await c.env.DB.prepare("UPDATE webhooks SET secret = ?, updated_at = ? WHERE id = ?")
.bind(secret, nowIso(), id)
.run();
if (!res.meta?.changes) return c.json({ error: "not found" }, 404);
return c.json({ ok: true, secret });
});
app.delete("/:id", async (c) => {
const id = c.req.param("id");
await c.env.DB.prepare("DELETE FROM webhook_deliveries WHERE webhook_id = ?").bind(id).run();
await c.env.DB.prepare("DELETE FROM webhooks WHERE id = ?").bind(id).run();
return c.json({ ok: true });
});
/** 投递日志 */
app.get("/:id/deliveries", async (c) => {
const id = c.req.param("id");
const { limit, offset } = pageParams(new URL(c.req.url));
const rows = await c.env.DB.prepare(
`SELECT d.*, e.subject, e.from_address
FROM webhook_deliveries d LEFT JOIN emails e ON e.id = d.email_id
WHERE d.webhook_id = ? ORDER BY d.created_at DESC LIMIT ? OFFSET ?`,
)
.bind(id, limit, offset)
.all();
return c.json({ items: rows.results });
});
/** 手动测试投递(使用最近一封邮件,若无则发空壳测试事件) */
app.post("/:id/test", async (c) => {
const id = c.req.param("id");
const hook = await c.env.DB.prepare("SELECT id, endpoint, secret, enabled FROM webhooks WHERE id = ?")
.bind(id)
.first<{ id: string; endpoint: string; secret: string; enabled: number }>();
if (!hook) return c.json({ error: "not found" }, 404);
const lastEmail = await c.env.DB.prepare(
"SELECT id, mailbox_id, from_address, to_addresses, subject, text_body, received_at FROM emails ORDER BY received_at DESC LIMIT 1",
).first<{ id: string; mailbox_id: string | null; from_address: string | null; to_addresses: string; subject: string | null; text_body: string | null; received_at: string }>();
const mailbox = lastEmail?.mailbox_id
? (await c.env.DB.prepare("SELECT address FROM mailboxes WHERE id = ?").bind(lastEmail.mailbox_id).first<{ address: string }>())?.address ?? null
: null;
const payload = lastEmail
? buildWebhookPayload({ id: lastEmail.id, mailboxAddress: mailbox, fromAddress: lastEmail.from_address, toAddresses: lastEmail.to_addresses, subject: lastEmail.subject, textBody: lastEmail.text_body, receivedAt: lastEmail.received_at })
: {
event: "email.received",
id: "test_" + crypto.randomUUID().slice(0, 8),
mailbox: "test@example.com",
from: "console@test.local",
to: ["test@example.com"],
subject: "WmailHub webhook test",
text: "This is a manual test delivery.",
received_at: nowIso(),
};
const result = await attemptDelivery(
{ id: hook.id, endpoint: hook.endpoint, secret: hook.secret },
"test",
payload,
undefined,
c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1",
);
return c.json(result);
});
/** 重投:把失败 delivery 重置为立即重试 */
app.post("/deliveries/:deliveryId/redeliver", async (c) => {
const deliveryId = c.req.param("deliveryId");
const res = await c.env.DB.prepare(
"UPDATE webhook_deliveries SET status = 'pending', next_retry_at = ?, last_error = NULL WHERE id = ?",
)
.bind(nowIso(), deliveryId)
.run();
if (!res.meta?.changes) return c.json({ error: "not found" }, 404);
return c.json({ ok: true });
});
return app;
}
+243
View File
@@ -0,0 +1,243 @@
/**
* Cloudflare API 客户端抽象。
*
* - RealCFApi:真实调用 api.cloudflare.com / dash.cloudflare.com(OAuth access token)
* - SimCFApi :本地 sim 模式。不访问外网,把"创建"的资源记录到 cf_sim_resources 表,
* 让 Add Node 自动安装链路在本地完整可演示、可测试。
*
* Console 通过 env.CF_OAUTH_CLIENT_ID/SECRET 判断模式;两者齐备为 real,否则 sim。
*/
import { renderEmailWorkerScript } from "../../../shared/src/index.ts";
import type { Env } from "../env.ts";
import { ids } from "../../../shared/src/index.ts";
import { nowIso } from "../util.ts";
/** # 成员变量: OAuth 授权端点(real 模式) **/
export const CF_OAUTH_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth";
/** # 成员变量: OAuth token 端点(real 模式) **/
export const CF_OAUTH_TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
/** # 成员变量: Cloudflare API 基址(real 模式) **/
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
/** # 成员变量: OAuth 申请权限(docs/08:最小权限——账户与 Zone 读、Email Routing 读写、Workers 脚本写) **/
export const CF_OAUTH_SCOPES = [
"account:read",
"zone:read",
"email_routing:edit",
"workers_scripts:edit",
].join(" ");
/** # 成员变量: 邮件发现的 Cloudflare 资源描述 **/
export interface CFAccount {
id: string;
name: string;
}
export interface CFZone {
id: string;
name: string;
account_id: string;
status: string;
}
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
export interface CFApi {
/** 用 code 换 access token(real);sim 直接签发假 token */
exchangeToken(code: string, redirectUri: string): Promise<{ accessToken: string; expiresIn: number }>;
/** 发现 token 可访问的账户 */
listAccounts(accessToken: string): Promise<CFAccount[]>;
/** 发现账户下的域名 Zone */
listZones(accessToken: string, accountId: string): Promise<CFZone[]>;
/** 创建或更新 Email Worker(幂等) */
putEmailWorker(opts: {
accessToken: string;
accountId: string;
scriptName: string;
ingressUrl: string;
ingressSecret: string;
}): Promise<{ id: string }>;
/** 开启 Zone 的 Email Routing(幂等) */
enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean }>;
/** 配置 Zone catch-all 规则指向 Email Worker(幂等) */
setCatchAllRule(opts: {
accessToken: string;
zoneId: string;
workerName: string;
}): Promise<{ ok: boolean }>;
}
/** # 函数
* 根据环境构造 Cloudflare API 客户端(real 或 sim)
*/
export function getCFApi(env: Env, db: Env["DB"]): CFApi {
const real = env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET && !env.WMAILHUB_SIM_CF;
if (real) return new RealCFApi(env);
return new SimCFApi(db);
}
/** # 成员变量: 真实 Cloudflare API 实现 **/
export class RealCFApi implements CFApi {
/** # 成员变量 **/
constructor(private env: Env) {}
async exchangeToken(code: string, redirectUri: string) {
const resp = await fetch(CF_OAUTH_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: this.env.CF_OAUTH_CLIENT_ID ?? "",
client_secret: this.env.CF_OAUTH_CLIENT_SECRET ?? "",
redirect_uri: redirectUri,
}),
});
if (!resp.ok) throw new Error(`cloudflare token exchange failed: ${resp.status}`);
const data = (await resp.json()) as { access_token: string; expires_in?: number };
return { accessToken: data.access_token, expiresIn: data.expires_in ?? 3600 };
}
private async api<T>(token: string, path: string, init?: RequestInit): Promise<T> {
const resp = await fetch(`${CF_API_BASE}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
const data = (await resp.json()) as { success: boolean; errors?: { message: string }[]; result: T };
if (!data.success) throw new Error(`cloudflare api ${path}: ${data.errors?.[0]?.message ?? resp.status}`);
return data.result;
}
listAccounts(token: string) {
return this.api<CFAccount[]>(token, "/accounts");
}
listZones(token: string, accountId: string) {
return this.api<CFZone[]>(token, `/zones?account.id=${encodeURIComponent(accountId)}&per_page=50`);
}
async putEmailWorker(opts: {
accessToken: string;
accountId: string;
scriptName: string;
ingressUrl: string;
ingressSecret: string;
}) {
// Email Worker 即带 email handler 的 Worker 脚本;ingress 地址与密钥以明文常量打进脚本,
// 避免给 Worker 配置 Console 凭据(docs/08:Worker 只持有 Node ingress secret)。
const script = renderEmailWorkerScript(opts.ingressUrl, opts.ingressSecret);
const form = new FormData();
form.append(
"metadata",
new Blob([JSON.stringify({ main_module: "worker.js", bindings: [] })], { type: "application/json" }),
);
form.append("worker.js", new Blob([script], { type: "application/javascript+module" }));
const resp = await fetch(
`${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
{
method: "PUT",
headers: { Authorization: `Bearer ${opts.accessToken}` },
body: form,
},
);
if (!resp.ok) throw new Error(`upload email worker failed: ${resp.status}`);
return { id: opts.scriptName };
}
async enableEmailRouting(opts: { accessToken: string; zoneId: string }) {
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing`, {
method: "POST",
body: JSON.stringify({ enabled: true, skip_wizard: true }),
});
return { enabled: true };
}
async setCatchAllRule(opts: { accessToken: string; zoneId: string; workerName: string }) {
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing/rules/catch_all`, {
method: "PUT",
body: JSON.stringify({
matchers: [{ type: "all" }],
actions: [{ type: "worker", value: [opts.workerName] }],
enabled: true,
name: "wmailhub catch-all",
}),
});
return { ok: true };
}
}
/** # 成员变量: 本地 sim 实现:资源写入 cf_sim_resources,行为与真实链路一致 **/
export class SimCFApi implements CFApi {
/** # 成员变量 **/
constructor(private db: Env["DB"]) {}
private async record(kind: string, refId: string, payload: unknown): Promise<void> {
await this.db
.prepare(
"INSERT INTO cf_sim_resources (id, kind, ref_id, payload, created_at) VALUES (?, ?, ?, ?, ?) " +
"ON CONFLICT(id) DO UPDATE SET payload = excluded.payload",
)
.bind(ids.simResource(), kind, refId, JSON.stringify(payload), nowIso())
.run();
}
async exchangeToken(_code: string, _redirectUri: string) {
return { accessToken: "sim_" + ids.oauth(), expiresIn: 3600 };
}
async listAccounts(_token: string): Promise<CFAccount[]> {
// sim 固定一个演示账户;幂等记录
const account = { id: "sim-account-0001", name: "WmailHub Sim Account" };
await this.record("account", account.id, account);
return [account];
}
async listZones(_token: string, accountId: string): Promise<CFZone[]> {
const zones = [
{
id: "sim-zone-example.test",
name: "example.test",
account_id: accountId,
status: "active",
},
];
for (const z of zones) await this.record("zone", z.id, z);
return zones;
}
async putEmailWorker(opts: {
accessToken: string;
accountId: string;
scriptName: string;
ingressUrl: string;
ingressSecret: string;
}) {
// 与真实实现同一模板渲染,但只记录不外发
await this.record("email_worker", opts.scriptName, {
account_id: opts.accountId,
ingress_url: opts.ingressUrl,
script: renderEmailWorkerScript(opts.ingressUrl, opts.ingressSecret).length,
});
return { id: opts.scriptName };
}
async enableEmailRouting(opts: { zoneId: string }) {
await this.record("routing_settings", opts.zoneId, { enabled: true });
return { enabled: true };
}
async setCatchAllRule(opts: { zoneId: string; workerName: string }) {
await this.record("routing_rule", opts.zoneId, {
type: "catch_all",
action: "worker",
value: opts.workerName,
});
return { ok: true };
}
}
+116
View File
@@ -0,0 +1,116 @@
/**
* TTL 清理与 orphan 清理(docs/06、docs/08):
* - cleanupExpiredEmails:expires_at <= now 的邮件 -> 先删 R2 对象(raw + 附件),全部成功才删行;
* R2 失败则保留 DB 记录留待下次 Cron 重试(幂等)。
* - cleanupOrphans:附件行存在但邮件已删 -> 删 R2 对象 + 附件行;
* delivery 指向已删邮件 -> 删 delivery 行(数据最小化)。
* - housekeeping:过期 nonce、过期 OAuth state 清理。
*/
import type { Env } from "../env.ts";
import { nowIso } from "../util.ts";
/** # 成员变量: 清理统计 **/
export interface CleanupStats {
expiredEmails: number;
deferredEmails: number;
orphanAttachments: number;
orphanDeliveries: number;
housekeeping: number;
errors: string[];
}
/** # 函数
* Cron 主入口:TTL 清理 + orphan 清理 + webhook 投递重试 + 琐碎清理。全程幂等。
*/
export async function runCron(
env: Env,
deliverDue: () => Promise<unknown>,
): Promise<CleanupStats> {
const stats: CleanupStats = {
expiredEmails: 0,
deferredEmails: 0,
orphanAttachments: 0,
orphanDeliveries: 0,
housekeeping: 0,
errors: [],
};
await cleanupExpiredEmails(env, stats);
await cleanupOrphans(env, stats);
await housekeeping(env, stats);
try {
await deliverDue();
} catch (err) {
stats.errors.push(`deliver: ${err instanceof Error ? err.message : String(err)}`);
}
return stats;
}
/** # 函数
* 删除过期邮件(R2 先行,全部成功才删 DB)
*/
export async function cleanupExpiredEmails(env: Env, stats: CleanupStats): Promise<void> {
const now = nowIso();
const expired = await env.DB.prepare(
"SELECT id, raw_storage_key FROM emails WHERE expires_at <= ? LIMIT 100",
)
.bind(now)
.all<{ id: string; raw_storage_key: string }>();
for (const email of expired.results) {
try {
const atts = await env.DB.prepare("SELECT storage_key FROM email_attachments WHERE email_id = ?")
.bind(email.id)
.all<{ storage_key: string | null }>();
const keys = [email.raw_storage_key, ...atts.results.map((a) => a.storage_key)].filter(
(k): k is string => Boolean(k),
);
if (keys.length > 0) await env.R2.delete(keys);
// R2 delete 幂等,成功后删除 DB 行
await env.DB.prepare("DELETE FROM email_attachments WHERE email_id = ?").bind(email.id).run();
await env.DB.prepare("DELETE FROM emails WHERE id = ?").bind(email.id).run();
stats.expiredEmails++;
} catch (err) {
// R2 失败:保留记录,下次重试
stats.deferredEmails++;
stats.errors.push(`cleanup email ${email.id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
/** # 函数
* orphan 清理:无主附件行、无主 delivery
*/
export async function cleanupOrphans(env: Env, stats: CleanupStats): Promise<void> {
const orphanAtts = await env.DB.prepare(
"SELECT a.id, a.storage_key FROM email_attachments a LEFT JOIN emails e ON e.id = a.email_id WHERE e.id IS NULL LIMIT 100",
).all<{ id: string; storage_key: string | null }>();
for (const att of orphanAtts.results) {
try {
if (att.storage_key) await env.R2.delete(att.storage_key);
await env.DB.prepare("DELETE FROM email_attachments WHERE id = ?").bind(att.id).run();
stats.orphanAttachments++;
} catch (err) {
stats.errors.push(`orphan attachment ${att.id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
const orphanDeliveries = await env.DB.prepare(
"SELECT d.id FROM webhook_deliveries d LEFT JOIN emails e ON e.id = d.email_id WHERE e.id IS NULL LIMIT 100",
).all<{ id: string }>();
for (const d of orphanDeliveries.results) {
await env.DB.prepare("DELETE FROM webhook_deliveries WHERE id = ?").bind(d.id).run();
stats.orphanDeliveries++;
}
}
/** # 函数
* 琐碎清理:过期 nonce、过期 OAuth state
*/
export async function housekeeping(env: Env, stats: CleanupStats): Promise<void> {
const now = nowIso();
const n1 = await env.DB.prepare("DELETE FROM nonces WHERE expires_at <= ?").bind(now).run();
const n2 = await env.DB.prepare("DELETE FROM oauth_states WHERE expires_at <= ?").bind(now).run();
stats.housekeeping += (n1.meta?.changes ?? 0) + (n2.meta?.changes ?? 0);
}
+281
View File
@@ -0,0 +1,281 @@
/**
* 邮件接收管线(docs/06):
* Console 收到 Node 转发的 raw MIME 后:
* 校验大小限制 -> postal-mime 解析 -> 关联 Mailbox(精确匹配,否则 Domain catch-all)->
* raw 写 R2、附件写 R2 -> emails/email_attachments 入库 -> 计算 expires_at ->
* 为启用的 Webhook 创建 delivery(投递失败不影响邮件入库)。
*
* malformed mail 不拒绝:尽量解析,失败时仍保存 raw 并以 fallback 字段入库(可观测)。
*/
import PostalMime from "postal-mime";
import type { Env } from "../env.ts";
import {
ids,
DEFAULT_RETENTION_SECONDS,
MAX_EMAIL_BYTES,
MAX_ATTACHMENTS,
MAX_ATTACHMENT_BYTES,
} from "../../../shared/src/index.ts";
import { nowIso } from "../util.ts";
/** # 成员变量: postal-mime 解析出的结构(仅本文件用到的字段) **/
interface ParsedMail {
messageId?: string;
from?: string;
to?: { address?: string }[];
cc?: { address?: string }[];
subject?: string;
text?: string;
html?: string;
attachments?: {
filename?: string;
mimeType?: string;
content: Uint8Array;
contentId?: string;
}[];
}
/** # 成员变量: 入站信封信息 **/
export interface IngestInput {
/** raw MIME 原文 **/
raw: Uint8Array;
/** SMTP 信封发件人(Email Worker 传递) **/
mailFrom: string;
/** SMTP 信封收件人(Email Worker 传递) **/
rcptTo: string;
/** 接收该邮件的 Node ID **/
nodeId: string;
}
/** # 成员变量: 入库结果 **/
export interface IngestResult {
id: string;
mailboxId: string | null;
expiresAt: string;
attachmentCount: number;
size: number;
}
/** # 函数
* 存储路径:emails/{yyyy}/{mm}/{dd}/{email_id}/raw.eml(docs/09)
*/
export function rawStorageKey(emailId: string, date = new Date()): string {
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
return `emails/${yyyy}/${mm}/${dd}/${emailId}/raw.eml`;
}
/** # 函数
* 附件路径:emails/{...}/{email_id}/attachments/{attachment_id}
*/
export function attachmentStorageKey(emailId: string, attachmentId: string, date = new Date()): string {
return rawStorageKey(emailId, date).replace(/raw\.eml$/, `attachments/${attachmentId}`);
}
/** # 函数
* 邮件入库主流程;大小/数量超限抛错(由 internal 路由转为 413)
*/
export async function ingestEmail(env: Env, input: IngestInput): Promise<IngestResult> {
if (input.raw.byteLength > MAX_EMAIL_BYTES) {
throw new IngestError(`email exceeds max size ${MAX_EMAIL_BYTES}`, 413);
}
const receivedAt = nowIso();
const receivedDate = new Date(receivedAt);
// MIME 解析;malformed 邮件降级入库
let parsed: ParsedMail | null = null;
try {
parsed = (await PostalMime.parse(input.raw)) as unknown as ParsedMail;
} catch {
parsed = null;
}
const toAddresses = (parsed?.to ?? [])
.map((t) => t.address ?? "")
.filter(Boolean)
.concat(parsed ? [] : input.rcptTo ? [input.rcptTo] : []);
const ccAddresses = (parsed?.cc ?? []).map((t) => t.address ?? "").filter(Boolean);
// postal-mime 的 from 是 { address, name } 对象(也可能是数组)
const fromAddress =
typeof parsed?.from === "string"
? parsed.from
: Array.isArray(parsed?.from)
? ((parsed.from[0] as { address?: string } | undefined)?.address ?? input.mailFrom)
: ((parsed?.from as { address?: string } | undefined)?.address ?? input.mailFrom);
// Mailbox 关联:任意收件地址精确匹配,否则该域名 catch-all,否则 NULL
const emailId = ids.email();
const mailboxId = await resolveMailbox(env, [
...toAddresses,
...(toAddresses.length === 0 && input.rcptTo ? [input.rcptTo] : []),
]);
// 保留策略:mailbox 覆盖 > 全局默认
let retention = DEFAULT_RETENTION_SECONDS;
if (mailboxId) {
const mb = await env.DB.prepare("SELECT retention_seconds FROM mailboxes WHERE id = ?")
.bind(mailboxId)
.first<{ retention_seconds: number | null }>();
if (mb?.retention_seconds) retention = mb.retention_seconds;
}
const expiresAt = new Date(receivedDate.getTime() + retention * 1000).toISOString();
// R2:raw MIME
const rawKey = rawStorageKey(emailId, receivedDate);
await env.R2.put(rawKey, input.raw, {
httpMetadata: { "Content-Type": "message/rfc822" },
});
// 附件(数量/单文件限额,超限附件跳过并继续)
const attachments = (parsed?.attachments ?? []).slice(0, MAX_ATTACHMENTS);
const attachmentRows: {
id: string;
email_id: string;
filename: string;
content_type: string;
size: number;
storage_key: string;
content_id: string | null;
}[] = [];
for (const att of attachments) {
if (att.content.byteLength > MAX_ATTACHMENT_BYTES) continue;
const attId = ids.attachment();
const key = attachmentStorageKey(emailId, attId, receivedDate);
await env.R2.put(key, att.content, {
httpMetadata: { "Content-Type": att.mimeType || "application/octet-stream" },
});
attachmentRows.push({
id: attId,
email_id: emailId,
filename: att.filename || "unnamed",
content_type: att.mimeType || "application/octet-stream",
size: att.content.byteLength,
storage_key: key,
content_id: att.contentId ?? null,
});
}
// 入库
await env.DB.prepare(
`INSERT INTO emails (id, node_id, mailbox_id, message_id, from_address, to_addresses, cc_addresses,
subject, text_body, html_body, raw_storage_key, size, received_at, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
emailId,
input.nodeId,
mailboxId,
parsed?.messageId ?? null,
fromAddress,
JSON.stringify(toAddresses),
JSON.stringify(ccAddresses),
parsed?.subject ?? "(unparsable mail)",
parsed?.text ?? null,
parsed?.html ?? null,
rawKey,
input.raw.byteLength,
receivedAt,
expiresAt,
receivedAt,
)
.run();
for (const row of attachmentRows) {
await env.DB.prepare(
"INSERT INTO email_attachments (id, email_id, filename, content_type, size, storage_key, content_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(row.id, row.email_id, row.filename, row.content_type, row.size, row.storage_key, row.content_id)
.run();
}
// Webhook delivery(邮件保存成功后创建;投递失败不影响入库)
await createDeliveriesForEmail(env, emailId, {
mailboxAddress: await mailboxAddress(env, mailboxId),
from: fromAddress,
to: toAddresses,
subject: parsed?.subject ?? "(unparsable mail)",
text: parsed?.text ?? null,
receivedAt,
});
return {
id: emailId,
mailboxId,
expiresAt,
attachmentCount: attachmentRows.length,
size: input.raw.byteLength,
};
}
/** # 成员变量: 带自定义 payload 的入站错误(HTTP 状态码) **/
export class IngestError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
}
}
/** # 函数
* 收件地址 -> mailbox 解析:精确匹配优先,其次域名 catch-all
*/
export async function resolveMailbox(env: Env, addresses: string[]): Promise<string | null> {
for (const addr of addresses) {
const a = addr.toLowerCase();
const hit = await env.DB.prepare("SELECT id FROM mailboxes WHERE address = ? AND status = 'active'")
.bind(a)
.first<{ id: string }>();
if (hit) return hit.id;
}
for (const addr of addresses) {
const a = addr.toLowerCase();
const domain = a.includes("@") ? a.split("@")[1] : null;
if (!domain) continue;
const catchAll = await env.DB.prepare(
`SELECT m.id FROM mailboxes m JOIN domains d ON m.domain_id = d.id
WHERE m.is_catch_all = 1 AND m.status = 'active' AND d.name = ?`,
)
.bind(domain)
.first<{ id: string }>();
if (catchAll) return catchAll.id;
}
return null;
}
async function mailboxAddress(env: Env, mailboxId: string | null): Promise<string | null> {
if (!mailboxId) return null;
const row = await env.DB.prepare("SELECT address FROM mailboxes WHERE id = ?")
.bind(mailboxId)
.first<{ address: string }>();
return row?.address ?? null;
}
/** # 函数
* 为新邮件创建全部启用 webhook 的 delivery(pending,立即可投)
*/
export async function createDeliveriesForEmail(
env: Env,
emailId: string,
ctx: {
mailboxAddress: string | null;
from?: string;
to: string[];
subject: string;
text: string | null;
receivedAt: string;
},
): Promise<void> {
const hooks = await env.DB.prepare("SELECT id FROM webhooks WHERE enabled = 1").all<{ id: string }>();
const now = nowIso();
for (const h of hooks.results) {
await env.DB.prepare(
"INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, next_retry_at, created_at) VALUES (?, ?, ?, 'pending', 0, ?, ?)",
)
.bind(ids.delivery(), h.id, emailId, now, now)
.run();
}
}
+339
View File
@@ -0,0 +1,339 @@
/**
* Node 自动安装管线(docs/02 流程):
* Add Node(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 ->
* Account/Zone 发现 -> 创建/更新 Email Worker -> 配置 Email Routing ->
* 创建 Domain/Mailbox -> Node Ready。
*
* 所有步骤幂等,重复执行不产生重复资源;每步状态写入 nodes.provision_steps,
* 供前端安装进度展示。全程不要求用户复制安装命令(docs/10 Phase 4)。
*/
import type { Env } from "../env.ts";
import { getCFApi, type CFApi } from "./cfapi.ts";
import { encryptSecret, decryptSecret } from "../db.ts";
import { ids, sha256Hex, DEFAULT_RETENTION_SECONDS } from "../../../shared/src/index.ts";
import { nowIso } from "../util.ts";
/** # 成员变量: 安装步骤定义(顺序即展示顺序) **/
export const PROVISION_STEPS = [
{ key: "oauth", label: "Cloudflare OAuth 授权" },
{ key: "identity", label: "Node identity 与 credential" },
{ key: "discover", label: "Account / Zone 发现" },
{ key: "email_worker", label: "Email Worker 创建/更新" },
{ key: "email_routing", label: "Email Routing 配置" },
{ key: "domains", label: "Domain 创建" },
{ key: "mailboxes", label: "Mailbox 创建" },
] as const;
/** # 成员变量: 步骤状态 **/
export type StepStatus = "pending" | "running" | "done" | "error";
/** # 函数
* 初始化步骤状态 JSON
*/
export function initialSteps(): Record<string, { status: StepStatus; error?: string }> {
const out: Record<string, { status: StepStatus; error?: string }> = {};
for (const s of PROVISION_STEPS) out[s.key] = { status: "pending" };
return out;
}
/** # 函数
* Node 的 Email Worker 脚本名(幂等:同一 Node 固定同名)
*/
export function emailWorkerNameFor(nodeId: string): string {
return `wmailhub-email-${nodeId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
}
/** # 函数
* Add Node:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。
* 返回 enrollment token 供 Node bootstrap(本地由 dev 脚本自动完成,生产在 UI 展示一次)。
*/
export async function createNode(
env: Env,
opts: { name: string; ingressUrl: string },
): Promise<{
nodeId: string;
enrollmentToken: string;
ingressSecret: string;
}> {
const nodeId = ids.node();
const now = nowIso();
const ingressSecret = "whing_" + crypto.randomUUID().replace(/-/g, "");
const enrollmentToken = "wenr_" + crypto.randomUUID().replace(/-/g, "");
await env.DB.prepare(
`INSERT INTO nodes (id, name, cloudflare_account_id, credential_id, status, version,
last_seen_at, ingress_url, ingress_secret_enc, enrollment_token, enrollment_expires_at,
provision_steps, created_at, updated_at)
VALUES (?, ?, NULL, NULL, 'pending_oauth', NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
nodeId,
opts.name,
opts.ingressUrl,
await encryptText(env, ingressSecret),
enrollmentToken,
new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
JSON.stringify(initialSteps()),
now,
now,
)
.run();
return { nodeId, enrollmentToken, ingressSecret };
}
/** # 函数
* Node bootstrap enrollment:用一次性 token 换取正式身份。
* 每次调用都会轮换出全新 credential(旧的撤销),返回的 secret 仅此一次可见。
*/
export async function enrollNode(
env: Env,
enrollmentToken: string,
): Promise<{
node_id: string;
credential_id: string;
credential_secret: string;
ingress_secret: string;
console_url: string | null;
} | null> {
const node = await env.DB.prepare(
"SELECT id, ingress_secret_enc, enrollment_expires_at FROM nodes WHERE enrollment_token = ? AND status != 'revoked'",
)
.bind(enrollmentToken)
.first<{ id: string; ingress_secret_enc: string; enrollment_expires_at: string }>();
if (!node) return null;
if (node.enrollment_expires_at && node.enrollment_expires_at < nowIso()) return null;
// 轮换:撤销旧 credential,签发新 credential
await env.DB.prepare(
"UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'",
)
.bind(nowIso(), node.id)
.run();
const credentialId = ids.credential();
const secret = "wns_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
await env.DB.prepare(
"INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES (?, ?, ?, ?, 'active', ?)",
)
.bind(credentialId, node.id, await sha256Hex(secret), await encryptSecret(env, secret), nowIso())
.run();
await env.DB.prepare(
"UPDATE nodes SET credential_id = ?, status = CASE WHEN status = 'pending_oauth' THEN status ELSE status END, updated_at = ? WHERE id = ?",
)
.bind(credentialId, nowIso(), node.id)
.run();
return {
node_id: node.id,
credential_id: credentialId,
credential_secret: secret,
ingress_secret: await decryptText(env, node.ingress_secret_enc),
console_url: null,
};
}
async function encryptText(env: Env, text: string): Promise<string> {
return encryptSecret(env, text);
}
async function decryptText(env: Env, blob: string): Promise<string> {
return decryptSecret(env, blob);
}
/** # 成员变量: 授权完成后的完整安装流程参数 **/
export interface ProvisionInput {
nodeId: string;
accessToken: string;
accountId?: string;
}
/** # 函数
* OAuth 授权成功后的自动安装主流程(幂等,可重复调用)。
* 返回是否全部成功;失败时步骤状态里带 error。
*/
export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
const cf: CFApi = getCFApi(env, env.DB);
const node = await env.DB.prepare(
"SELECT id, name, ingress_url, ingress_secret_enc FROM nodes WHERE id = ?",
)
.bind(input.nodeId)
.first<{ id: string; name: string; ingress_url: string; ingress_secret_enc: string }>();
if (!node) return { ok: false, error: "node not found" };
const steps = await loadSteps(env, input.nodeId);
const mark = async (key: string, status: StepStatus, error?: string) => {
steps[key] = { status, error };
await env.DB.prepare("UPDATE nodes SET provision_steps = ?, updated_at = ? WHERE id = ?")
.bind(JSON.stringify(steps), nowIso(), input.nodeId)
.run();
};
try {
await env.DB.prepare("UPDATE nodes SET status = 'provisioning', updated_at = ? WHERE id = ?")
.bind(nowIso(), input.nodeId)
.run();
// 1. OAuth 授权
await mark("oauth", "done");
// 2. Node identity/credential(在 Add Node 与 enrollment 时完成)
await mark("identity", "done");
// 3. Account/Zone 发现
await mark("discover", "running");
const accounts = await cf.listAccounts(input.accessToken);
if (accounts.length === 0) throw new Error("no cloudflare account accessible");
const account = input.accountId
? accounts.find((a) => a.id === input.accountId) ?? accounts[0]
: accounts[0];
await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, updated_at = ? WHERE id = ?")
.bind(account.id, nowIso(), input.nodeId)
.run();
const zones = await cf.listZones(input.accessToken, account.id);
await mark("discover", "done");
// 4. Email Worker(幂等上传)
await mark("email_worker", "running");
const workerName = emailWorkerNameFor(input.nodeId);
const ingressSecret = await decryptText(env, node.ingress_secret_enc);
await cf.putEmailWorker({
accessToken: input.accessToken,
accountId: account.id,
scriptName: workerName,
ingressUrl: node.ingress_url,
ingressSecret,
});
await mark("email_worker", "done");
// 5+6. 每个 Zone:启用 Email Routing + catch-all -> Worker,并 upsert Domain
await mark("email_routing", "running");
for (const zone of zones) {
await cf.enableEmailRouting({ accessToken: input.accessToken, zoneId: zone.id });
await cf.setCatchAllRule({
accessToken: input.accessToken,
zoneId: zone.id,
workerName,
});
await upsertDomain(env, {
cloudflareAccountId: account.id,
nodeId: input.nodeId,
name: zone.name,
zoneId: zone.id,
workerId: workerName,
});
}
await mark("email_routing", "done");
await mark("domains", "done");
// 7. 每个 Domain 创建 catch-all Mailbox(*@domain)
await mark("mailboxes", "running");
const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE node_id = ?")
.bind(input.nodeId)
.all<{ id: string; name: string }>();
for (const d of domainRows.results) {
await upsertMailbox(env, {
domainId: d.id,
address: `*@${d.name}`,
isCatchAll: true,
});
}
await mark("mailboxes", "done");
await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?")
.bind(nowIso(), input.nodeId)
.run();
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// 标记第一个未完成步骤为 error
for (const s of PROVISION_STEPS) {
if (steps[s.key]?.status === "running") {
await mark(s.key, "error", message);
break;
}
}
await env.DB.prepare("UPDATE nodes SET status = 'error', updated_at = ? WHERE id = ?")
.bind(nowIso(), input.nodeId)
.run();
return { ok: false, error: message };
}
}
/** # 函数
* 读取并解析 node 的步骤状态
*/
export async function loadSteps(env: Env, nodeId: string) {
const row = await env.DB.prepare("SELECT provision_steps FROM nodes WHERE id = ?")
.bind(nodeId)
.first<{ provision_steps: string }>();
if (!row?.provision_steps) return initialSteps();
try {
return JSON.parse(row.provision_steps) as Record<string, { status: StepStatus; error?: string }>;
} catch {
return initialSteps();
}
}
/** # 函数
* upsert Domain(按 account+name 幂等)
*/
export async function upsertDomain(
env: Env,
d: {
cloudflareAccountId: string;
nodeId: string;
name: string;
zoneId: string;
workerId: string;
},
): Promise<string> {
const existing = await env.DB.prepare(
"SELECT id FROM domains WHERE cloudflare_account_id = ? AND name = ?",
)
.bind(d.cloudflareAccountId, d.name)
.first<{ id: string }>();
const now = nowIso();
if (existing) {
await env.DB.prepare(
"UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?",
)
.bind(d.nodeId, d.zoneId, d.workerId, now, existing.id)
.run();
return existing.id;
}
const id = ids.domain();
await env.DB.prepare(
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)",
)
.bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId, d.zoneId, now, now)
.run();
return id;
}
/** # 函数
* upsert Mailbox(按 address 幂等)
*/
export async function upsertMailbox(
env: Env,
m: { domainId: string; address: string; isCatchAll?: boolean; retentionSeconds?: number },
): Promise<string> {
const existing = await env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?")
.bind(m.address.toLowerCase())
.first<{ id: string }>();
const now = nowIso();
if (existing) return existing.id;
const id = ids.mailbox();
await env.DB.prepare(
"INSERT INTO mailboxes (id, domain_id, address, status, retention_seconds, is_catch_all, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?)",
)
.bind(
id,
m.domainId,
m.address.toLowerCase(),
m.retentionSeconds ?? DEFAULT_RETENTION_SECONDS,
m.isCatchAll ? 1 : 0,
now,
now,
)
.run();
return id;
}
+202
View File
@@ -0,0 +1,202 @@
/**
* Webhook 投递与重试(docs/06、docs/07):
* - payload 按文档 JSON 结构(event=email.received)
* - 签名头:X-WmailHub-Event / Delivery / Timestamp / Signature
* signature = HMAC-SHA256(webhook_secret, `${timestamp}.${rawBody}`)
* - 2xx 成功;5xx/超时重试(指数退避);4xx 记失败并按策略重试到上限
* - endpoint 每次投递前重过 SSRF 校验(静态 + DNS 解析,本地运行时)
*/
import type { Env } from "../env.ts";
import {
hmacSha256Hex,
assertWebhookUrlSafe,
type HostResolver,
WEBHOOK_HEADERS,
WEBHOOK_TIMEOUT_MS,
WEBHOOK_MAX_ATTEMPTS,
WEBHOOK_RETRY_BASE_MS,
} from "../../../shared/src/index.ts";
import { nowIso } from "../util.ts";
/** # 成员变量: Webhook 投递 payload 结构(docs/07) **/
export function buildWebhookPayload(email: {
id: string;
mailboxAddress: string | null;
fromAddress: string | null;
toAddresses: string;
subject: string | null;
textBody: string | null;
receivedAt: string;
}): Record<string, unknown> {
return {
event: "email.received",
id: email.id,
mailbox: email.mailboxAddress,
from: email.fromAddress,
to: safeParseArray(email.toAddresses),
subject: email.subject,
text: email.textBody,
received_at: email.receivedAt,
};
}
function safeParseArray(s: string | null): string[] {
if (!s) return [];
try {
const v = JSON.parse(s);
return Array.isArray(v) ? v.map(String) : [];
} catch {
return [];
}
}
/** # 函数
* 执行一次投递;返回是否成功与错误信息(不修改库)
*/
export async function attemptDelivery(
webhook: { id: string; endpoint: string; secret: string },
deliveryId: string,
payload: Record<string, unknown>,
resolve?: HostResolver,
allowPrivateHosts = false,
): Promise<{ ok: boolean; status?: number; error?: string }> {
const unsafe = await assertWebhookUrlSafe(webhook.endpoint, resolve, allowPrivateHosts);
if (unsafe) return { ok: false, error: `ssrf guard: ${unsafe}` };
const body = JSON.stringify(payload);
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = await hmacSha256Hex(webhook.secret, `${timestamp}.${body}`);
try {
const resp = await fetch(webhook.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
[WEBHOOK_HEADERS.event]: "email.received",
[WEBHOOK_HEADERS.delivery]: deliveryId,
[WEBHOOK_HEADERS.timestamp]: timestamp,
[WEBHOOK_HEADERS.signature]: signature,
},
body,
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
});
if (resp.ok) return { ok: true, status: resp.status };
return { ok: false, status: resp.status, error: `endpoint responded ${resp.status}` };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
/** # 函数
* 计算第 attempt 次失败后的下次重试时间(指数退避)
*/
export function nextRetryIso(attemptCount: number): string {
const delay = WEBHOOK_RETRY_BASE_MS * 2 ** attemptCount;
return new Date(Date.now() + delay).toISOString();
}
/** # 函数
* Cron 驱动的投递循环:取到期 delivery 逐个投递并落状态。
* 返回处理统计(供日志与 admin 接口)。
*/
export async function processDueDeliveries(
env: Env,
resolve?: HostResolver,
limit = 20,
allowPrivateHosts = false,
): Promise<{ processed: number; success: number; failed: number; exhausted: number }> {
const now = nowIso();
const due = await env.DB.prepare(
`SELECT d.id AS delivery_id, d.attempt_count, w.id AS webhook_id, w.endpoint, w.secret, w.enabled,
e.id AS email_id, e.mailbox_id, e.from_address, e.to_addresses, e.subject, e.text_body,
e.received_at, e.expires_at
FROM webhook_deliveries d
JOIN webhooks w ON w.id = d.webhook_id
JOIN emails e ON e.id = d.email_id
WHERE d.status IN ('pending', 'failed')
AND d.attempt_count < ?
AND (d.next_retry_at IS NULL OR d.next_retry_at <= ?)
ORDER BY d.created_at
LIMIT ?`,
)
.bind(WEBHOOK_MAX_ATTEMPTS, now, limit)
.all<{
delivery_id: string;
attempt_count: number;
webhook_id: string;
endpoint: string;
secret: string;
enabled: number;
email_id: string;
mailbox_id: string | null;
from_address: string | null;
to_addresses: string;
subject: string | null;
text_body: string | null;
received_at: string;
expires_at: string;
}>();
let success = 0;
let failed = 0;
let exhausted = 0;
for (const row of due.results) {
if (row.expires_at <= now) {
// 邮件已过期:投递失去意义,直接结束(数据最小化)
await env.DB.prepare("UPDATE webhook_deliveries SET status = 'exhausted', last_error = 'email expired' WHERE id = ?")
.bind(row.delivery_id)
.run();
exhausted++;
continue;
}
if (!row.enabled) continue;
const mailbox = row.mailbox_id
? (
await env.DB.prepare("SELECT address FROM mailboxes WHERE id = ?")
.bind(row.mailbox_id)
.first<{ address: string }>()
)?.address ?? null
: null;
const payload = buildWebhookPayload({
id: row.email_id,
mailboxAddress: mailbox,
fromAddress: row.from_address,
toAddresses: row.to_addresses,
subject: row.subject,
textBody: row.text_body,
receivedAt: row.received_at,
});
const result = await attemptDelivery(
{ id: row.webhook_id, endpoint: row.endpoint, secret: row.secret },
row.delivery_id,
payload,
resolve,
allowPrivateHosts,
);
const attempts = row.attempt_count + 1;
if (result.ok) {
await env.DB.prepare(
"UPDATE webhook_deliveries SET status = 'success', attempt_count = ?, delivered_at = ?, last_error = NULL, next_retry_at = NULL WHERE id = ?",
)
.bind(attempts, nowIso(), row.delivery_id)
.run();
success++;
} else if (attempts >= WEBHOOK_MAX_ATTEMPTS) {
await env.DB.prepare(
"UPDATE webhook_deliveries SET status = 'exhausted', attempt_count = ?, last_error = ? WHERE id = ?",
)
.bind(attempts, result.error ?? "unknown", row.delivery_id)
.run();
exhausted++;
} else {
await env.DB.prepare(
"UPDATE webhook_deliveries SET status = 'failed', attempt_count = ?, last_error = ?, next_retry_at = ? WHERE id = ?",
)
.bind(attempts, result.error ?? "unknown", nextRetryIso(attempts), row.delivery_id)
.run();
failed++;
}
}
return { processed: due.results.length, success, failed, exhausted };
}
+40
View File
@@ -0,0 +1,40 @@
/**
* DNS 解析器:本地 Bun/Node 运行时用 node:dns 做真实解析(SSRF 防护 DNS rebinding 检查)。
* Workers 运行时无 DNS API,返回 undefined 走静态 IP 字面量检查(Workers 出口本身无法
* 访问私网,风险由平台边界兜底)。
*/
let cachedResolve: ((host: string) => Promise<string[]>) | null | undefined;
/** # 函数
* 获取主机名解析函数;非 Node/Bun 环境返回 undefined
*/
export function resolveHost(host: string): Promise<string[]> {
if (cachedResolve === undefined) {
cachedResolve = detectResolver();
}
if (!cachedResolve) return Promise.resolve([]);
return cachedResolve(host);
}
function detectResolver(): ((host: string) => Promise<string[]>) | null {
const hasProcess = typeof process !== "undefined" && Boolean(process.versions);
if (!hasProcess) return null;
// 动态 require,避免 Workers 打包器静态分析 node:dns
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const dns = (globalThis as { process?: { getBuiltinModule?: (m: string) => unknown } })
.process?.getBuiltinModule?.("node:dns") as
| { promises?: { lookup: (h: string, o: { all: true }) => Promise<{ address: string }[]> } }
| undefined;
if (dns?.promises?.lookup) {
return async (host) => {
const res = await dns.promises!.lookup(host, { all: true });
return res.map((r) => r.address);
};
}
} catch {
// ignore
}
return null;
}
+47
View File
@@ -0,0 +1,47 @@
/** Console 通用小工具 */
/** # 函数
* 当前时间 ISO 字符串(UTC)
*/
export function nowIso(): string {
return new Date().toISOString();
}
/** # 函数
* unix 秒转 ISO
*/
export function isoAfter(seconds: number, from: number = Date.now()): string {
return new Date(from + seconds * 1000).toISOString();
}
/** # 函数
* 读取 body 为 JSON 对象,失败返回 {}
*/
export async function readJson<T = Record<string, unknown>>(request: Request): Promise<T> {
try {
return (await request.json()) as T;
} catch {
return {} as T;
}
}
/** # 函数
* 分页参数解析
*/
export function pageParams(url: URL): { limit: number; offset: number } {
const limit = Math.min(Math.max(Number(url.searchParams.get("limit")) || 20, 1), 100);
const offset = Math.max(Number(url.searchParams.get("offset")) || 0, 0);
return { limit, offset };
}
/** # 函数
* 安全 JSON 解析,失败返回默认值
*/
export function parseJsonOr<T>(text: string | null | undefined, fallback: T): T {
if (!text) return fallback;
try {
return JSON.parse(text) as T;
} catch {
return fallback;
}
}
+11
View File
@@ -0,0 +1,11 @@
/**
* 版本号:一律以项目根 package.json 的 version 字段为准(全局规范),
* 运行时/构建时从 package.json 读取,禁止手写常量。
*/
import pkg from "../../package.json";
/** # 成员变量: 应用版本(来自 package.json) **/
export const APP_VERSION: string = pkg.version;
/** # 成员变量: 应用名称 **/
export const APP_NAME: string = "WmailHub";
+28
View File
@@ -0,0 +1,28 @@
/**
* Console Cloudflare Workers 入口(生产部署):
* fetch -> Hono 应用(前端资产 + 管理 API + OAuth 回调)
* scheduled -> Cron Trigger:TTL 清理 + Webhook 重试(docs/09 建议每 15 分钟)
*/
import { createApp } from "./app.ts";
import type { Env, ScheduledEvent, WorkersExecutionContext } from "./env.ts";
import { runCron } from "./services/cleanup.ts";
import { processDueDeliveries } from "./services/webhook_delivery.ts";
/** # 成员变量: Hono 应用实例 **/
const app = createApp();
/** # 成员变量: Workers 导出(fetch + scheduled) **/
export default {
async fetch(request: Request, env: Env, ctx: WorkersExecutionContext): Promise<Response> {
return app.fetch(request, env, ctx as never);
},
async scheduled(event: ScheduledEvent, env: Env, ctx: WorkersExecutionContext): Promise<void> {
ctx.waitUntil(
runCron(env, () => processDueDeliveries(env)).then((stats) => {
console.log(JSON.stringify({ t: new Date().toISOString(), lvl: "info", msg: "cron", ...stats }));
}),
);
},
};
+39
View File
@@ -0,0 +1,39 @@
// WmailHub Console —— Cloudflare Workers 部署配置(生产)
// 本地开发不需要 wrangler:直接 `bun run dev`。首次部署前:
// 1. `bunx wrangler d1 create wmailhub` -> 填 database_id
// 2. `bunx wrangler r2 bucket create wmailhub`
// 3. `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql`
// 4. `bunx wrangler secret put SESSION_SECRET` (>=32 位随机串)
// 5. 配置 CF_OAUTH_CLIENT_ID/SECRET/REDIRECT_URI vars(真实 OAuth;缺省走 sim)
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "wmailhub-console",
"main": "src/worker.ts",
"compatibility_date": "2026-09-01",
"assets": {
"directory": "./public",
"binding": "ASSETS",
"not_found_handling": "single-page-application"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "wmailhub",
"database_id": "REPLACE_AFTER_D1_CREATE"
}
],
"r2_buckets": [
{
"binding": "R2",
"bucket_name": "wmailhub"
}
],
"triggers": {
// TTL 清理每 15 分钟(docs/09 建议);Webhook 重试每 1 分钟
"crons": ["*/1 * * * *"]
},
"vars": {
// PUBLIC_BASE_URL 改为你的 Console 域名;OAuth redirect 需要在 Cloudflare Dashboard 登记
"PUBLIC_BASE_URL": "https://wmailhub-console.<your-subdomain>.workers.dev"
}
}
+100
View File
@@ -0,0 +1,100 @@
# WmailHub 开发设计总览
## 1. 项目定位
WmailHub 是一个基于 Cloudflare Email Workers 的轻量邮件接收与转发平台。
核心原则:
- Cloudflare 负责邮件基础设施。
- Console 是控制平面,包含前端与管理 Node。
- Node 是邮件数据转发平面,无前端、无业务判断。
- Console 通过 Cloudflare OAuth 自动完成 Node、Domain、Mailbox、Email Worker 的配置。
- Node 只能处理被授权 Cloudflare Account 下的域名邮箱。
- Node 与 Console 的通信必须使用 HTTPS + 应用层签名。
- 邮件默认短期保存,例如 1 天,过期自动删除。
- 邮件进入 Console 后才能进入 Webhook、API、MCP 等业务能力。
## 2. 总体架构
```text
Internet
|
v
Cloudflare Email Routing
|
v
Cloudflare Email Worker
|
v
Node
| HTTPS + Request Signature
v
Console Management Node
|
+--> Database
+--> Webhook
+--> API
+--> MCP
+--> Frontend
```
## 3. Console
Console = Frontend + Management Node。
负责:Cloudflare OAuth、Cloudflare Account、Node、Domain、Mailbox、Email、Webhook、API、MCP、Node 安装与配置、数据存储、TTL 清理调度。
用户不执行 Node 安装命令。点击 Add Node 后进入 Cloudflare OAuth,授权后由 Cloudflare API 自动完成安装和配置。
## 4. Node
Node 是纯后端组件,只做:接收 Cloudflare Email Worker 的邮件、将邮件安全转发到 Console。
Node 不做 Domain 判断、Mailbox 判断、邮件业务路由、Webhook、MCP、API、用户权限、邮件长期存储。
Node 收到什么就转发什么。
## 5. 权限边界
一个 Node 与一个 Cloudflare Account 建立绑定。Node A 只能处理 Account A 下配置的 Email Worker,不允许处理其他 Account。
Domain 与 Mailbox 的具体业务信息由 Console 管理,Node 不需要理解这些业务字段。
## 6. 核心邮件流程
```text
Email
|
v
Cloudflare Email Routing
|
v
Email Worker
|
v
Node
|
v
Console
|
+--> Store
+--> Webhook
+--> API
+--> MCP
```
## 7. 生命周期
默认邮件 TTL 为 1 天。数据库记录 expires_at,Cron 定期删除过期记录及对应 R2 对象。
## 8. 第一版明确不做
- 自建 SMTP Server
- IMAP / POP3
- 发件服务器
- 完整邮箱客户端
- 复杂邮件规则引擎
- AI 邮件分析
- 多租户复杂组织系统
- Node 端业务路由
+47
View File
@@ -0,0 +1,47 @@
# WmailHub 系统架构
## Console
Console = Frontend + Management Node,是系统控制中心。
负责 Cloudflare OAuth、Node、Domain、Mailbox、Email、Webhook、API、MCP 以及数据存储。
## Node
Node 是纯后端、无前端的邮件转发节点。
唯一核心流程:Cloudflare Email Worker -> Node -> Console。
Node 不负责 Domain、Mailbox、Webhook、MCP、API、用户权限或业务路由。
## Cloudflare Worker
Email Worker 是邮件入口,只负责接收 Cloudflare Email Event 并安全转发给 Node。
## 控制平面
```text
Cloudflare Account
+-- Node
+-- Domain
+-- Mailbox
+-- Email Worker
```
这些资源关系由 Console 管理。
## 数据平面
```text
Email Worker -> Node -> Console
```
Node 不参与业务决策。
## Node Scope
第一版一个 Node 绑定一个 Cloudflare Account。Node A 不允许处理 Account B 的资源。
## 扩展
未来可以有多个 Node,例如 Node JP、Node US、Node EU。每个 Node 默认仍绑定一个 Cloudflare Account。
+11
View File
@@ -0,0 +1,11 @@
# Cloudflare OAuth 与自动安装
Console 的 Add Node 入口必须打开 Cloudflare OAuth 授权页面。用户授权后,Console 通过 Cloudflare API 自动完成 Node、Email Worker、Domain、Mailbox 的配置。
用户不执行 shell 安装命令,也不手工创建 API Token。
流程:Console -> Add Node -> Cloudflare OAuth -> 授权回调 -> 获取 Account/Zone -> 创建 Node Identity -> 创建 Node Credential -> 创建或更新 Email Worker -> 配置 Email Routing -> 创建 Domain/Mailbox -> Node Ready。
Cloudflare credential 与 Node credential 必须分离。Cloudflare credential 加密存储,不进入前端和日志。
安装过程必须幂等,重复执行不能创建重复资源。OAuth state、授权回调和 Account ownership 必须验证。
+44
View File
@@ -0,0 +1,44 @@
# Node 设计
Node 是无前端、纯后端的邮件转发节点。
核心流程:
```text
Email Worker -> Node -> Console
```
## Node 职责
- 接收 Email Worker 请求
- 验证 Worker 请求
- 获取邮件
- 安全转发到 Console
- 有限重试临时网络错误
- 心跳
- 上报版本和状态
## Node 不负责
- Domain 路由
- Mailbox 路由
- Webhook
- MCP
- API
- 用户权限
- 邮件业务存储
- 邮件规则
Node 收到什么就转发什么。
## Node Scope
Node 只能服务创建它的 Cloudflare Account。不得通过普通业务请求把 Node 任意绑定到其他 Account。
## 配置
Node 至少需要 node_id、cloudflare_account_id、console_endpoint、credential。
credential secret 必须安全保存。
Node 可以有限重试 Console 网络错误,但不承担长期邮件队列职责。
+49
View File
@@ -0,0 +1,49 @@
# Node 与 Console 通信协议
Node -> Console 必须使用 HTTPS + HMAC 请求签名 + timestamp + nonce + body hash。
## Credential
```text
node_id
credential_id
credential_secret
cloudflare_account_id
```
Console 保存 secret 的安全摘要;Node 保存实际 secret。
## 签名
```text
body_hash = SHA256(raw_body)
canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + body_hash
signature = HMAC-SHA256(secret, canonical)
```
请求头:
```text
Authorization: WmailHub-Node <node_id>
X-WmailHub-Timestamp: <unix_seconds>
X-WmailHub-Nonce: <random>
X-WmailHub-Body-SHA256: <hex>
X-WmailHub-Signature: <hex>
X-WmailHub-Protocol: 1
```
Console 拒绝 timestamp 超窗、重复 nonce、错误 signature、错误 body hash、已撤销 credential。默认 timestamp window 建议 5 分钟。
## API
```text
POST /api/internal/nodes/{node_id}/emails
POST /api/internal/nodes/{node_id}/heartbeat
GET /api/internal/nodes/{node_id}/config
```
邮件请求推荐 `Content-Type: message/rfc822`,body 直接使用 raw MIME。
Console 可以立即撤销 credential。Node 收到 401/403 后不得无限重试。
协议必须版本化。
+57
View File
@@ -0,0 +1,57 @@
# 数据模型
核心实体:CloudflareAccount、Node、Domain、Mailbox、Email、EmailAttachment、Webhook、WebhookDelivery、OAuthConnection。
## nodes
```text
id, name, cloudflare_account_id, credential_id, status, version, last_seen_at, created_at, updated_at
```
## domains
```text
id, cloudflare_account_id, node_id, name, status, worker_id, created_at, updated_at
```
## mailboxes
```text
id, domain_id, address, status, retention_seconds, created_at, updated_at
```
## emails
```text
id, node_id, mailbox_id nullable, message_id, from_address, to_addresses,
cc_addresses, subject, text_body, html_body, raw_storage_key, size,
received_at, expires_at, created_at
```
Node 可以不知道 mailbox_id。Console 收到邮件后,根据邮件内容和自身配置关联。
## email_attachments
```text
id, email_id, filename, content_type, size, storage_key, content_id
```
## webhooks
```text
id, name, endpoint, secret, enabled, created_at, updated_at
```
## webhook_deliveries
```text
id, webhook_id, email_id, status, attempt_count, next_retry_at, last_error, delivered_at, created_at
```
## oauth_connections
```text
id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at
```
OAuth token 必须加密存储。所有 Email 必须有 expires_at,默认 retention 为 86400 秒。
+42
View File
@@ -0,0 +1,42 @@
# Email 生命周期与 TTL
## 接收
```text
Cloudflare -> Email Worker -> Node -> Console
```
Console 验证 Node 身份和请求完整性,然后解析 MIME、提取 header、text/html 和附件,保存元数据,raw MIME/大附件写入 R2,计算 expires_at,并创建 Webhook Delivery。
## TTL
默认保留 1 天:
```text
received_at = 2026-09-30T10:00:00Z
expires_at = 2026-10-01T10:00:00Z
```
Retention 可按 Mailbox 配置。
## Cron
使用 Cloudflare Cron Trigger,建议每 15 分钟执行一次清理。实际频率根据当前 Cloudflare 限制调整。
```text
Cron -> 查询 expires_at <= now -> 删除 R2 -> 删除附件 -> 删除 Email
```
删除必须幂等。
## Webhook
邮件保存成功后再执行 Webhook。Webhook 失败不能导致邮件重新进入 Node。
## 异常
R2 删除失败时保留数据库记录并记录 cleanup error,下一次 Cron 重试。数据库已删除但 R2 删除失败时,需要 orphan cleanup。
## 数据最小化
邮件、附件、raw MIME、Webhook Delivery 和临时日志都应有生命周期,不做无限期保存。
+45
View File
@@ -0,0 +1,45 @@
# Webhook、API 与 MCP
Webhook、API、MCP 全部属于 Console,Node 不实现这些能力。
## Webhook
邮件保存成功后创建 delivery。Payload:
```json
{
"event": "email.received",
"id": "msg_xxx",
"mailbox": "hello@example.com",
"from": "user@example.org",
"to": ["hello@example.com"],
"subject": "Hello",
"text": "Hello World",
"received_at": "2026-09-30T10:00:00Z"
}
```
Webhook 必须签名,建议提供 X-WmailHub-Event、X-WmailHub-Delivery、X-WmailHub-Timestamp、X-WmailHub-Signature。
2xx 为成功;5xx 和 timeout 重试;4xx 按策略处理。使用指数退避和最大尝试次数。
## API
```text
/api/v1/nodes
/api/v1/domains
/api/v1/mailboxes
/api/v1/emails
/api/v1/webhooks
/api/v1/mcp
```
API 属于 Console。
## MCP
MCP 允许 Agent 查询邮箱、获取邮件、查询邮件状态、管理邮箱和 Webhook。Node 不暴露 MCP。
MCP/API 获取邮件时必须检查用户权限、邮件是否过期、mailbox scope 和 API credential scope。
Webhook endpoint 必须防 SSRF,至少阻止 localhost、private IPv4/IPv6、link-local、metadata endpoint 和 DNS rebinding。
+47
View File
@@ -0,0 +1,47 @@
# 安全设计
## 安全边界
```text
Cloudflare Email Worker
|
| authenticated request
v
Node
|
| TLS + HMAC
v
Console
```
## Cloudflare OAuth
OAuth Token 加密存储,不进入前端和日志,也不作为 Node credential。
## Node Credential
由 Console 自动生成。用户不手工配置。支持撤销和重新 enrollment。
## 请求签名
所有 Node -> Console 请求必须签名,签名覆盖 method、path、timestamp、nonce、body hash,防止篡改和 replay。
## Worker -> Node
Worker 使用 Node 专用 ingress secret。Worker 不应拥有 Console credential。
## 最小权限
Cloudflare OAuth 只申请业务所需权限。
## 日志
禁止记录 Cloudflare OAuth token、Node secret、Webhook secret、Authorization header 和完整邮件正文。
## 附件限制
限制单文件大小、单邮件总大小、文件数量和 MIME header 大小,避免资源耗尽。
## 数据保留
默认 Email TTL 为 1 天,过期自动删除。
+42
View File
@@ -0,0 +1,42 @@
# Cloudflare 部署设计
## 服务
第一版目标使用 Cloudflare Workers、Email Workers / Email Routing、D1、R2、Cron Triggers、OAuth;Queues 在需要可靠异步投递时再加入,并以当前 Cloudflare 限制为准。
## Console
Console 本身部署在 Cloudflare Workers:
```text
Worker
+-- Frontend Assets
+-- Management API
+-- OAuth Callback
+-- Cron Handler
```
## Node
Node 是独立纯后端程序,无前端。可以部署在用户服务器、云服务器或其他可访问 Console 的环境,但 Cloudflare Email Worker 必须能访问 Node ingress。
## Email Worker
Email Worker 只接收 Email Event 并安全转发到 Node,不保存业务数据库。
## R2
用于 raw MIME 和 attachments:
```text
emails/{yyyy}/{mm}/{dd}/{email_id}/raw.eml
emails/{yyyy}/{mm}/{dd}/{email_id}/attachments/{attachment_id}
```
## D1
保存 nodes、domains、mailboxes、emails、webhooks、webhook deliveries、oauth connections。
## Cron
至少实现 cleanup job,建议每 15 分钟一次,具体以当前 Cloudflare 限制调整。
+61
View File
@@ -0,0 +1,61 @@
# ZCODE 实施计划
## Phase 1 项目骨架
```text
WmailHub/
console/
node/
worker/
docs/
```
Console、Node、Worker 必须边界清晰,共享协议独立。
## Phase 2 Console
实现 Console Web、Management API、数据库、认证、Node CRUD、Domain CRUD、Mailbox CRUD、Email CRUD。
## Phase 3 Cloudflare OAuth
实现 OAuth start、callback、state、token exchange、Account/Zone discovery、加密 token storage。
## Phase 4 自动 Node 安装
Add Node 后完成:OAuth、Node identity、Node credential、Email Worker、Worker -> Node、Email Routing、Domain、Mailbox、Node heartbeat。正常流程禁止要求用户复制安装命令。
## Phase 5 Node
实现 bootstrap、secure config、Email Worker ingress、Console egress、HMAC signing、heartbeat、有限 retry、graceful shutdown、structured logging。
Node 不实现业务路由。
## Phase 6 Email Pipeline
```text
Cloudflare -> Worker -> Node -> Console -> D1/R2
```
测试 plain text、HTML、MIME、attachment、多收件人、大正文和 malformed mail。
## Phase 7 Webhook
实现 CRUD、secret、signing、delivery、retry、timeout、失败状态和日志。
## Phase 8 TTL
实现 retention configuration、expires_at、Cron cleanup、R2 cleanup 和 orphan cleanup。默认 1 day。
## Phase 9 API / MCP
实现 Console API,并提供 list_mailboxes、list_emails、get_email、create_mailbox、delete_mailbox、list_webhooks 等 MCP 工具。权限必须基于 Console credential。
## Phase 10 安全测试
测试 OAuth CSRF、credential revoke、replay attack、伪造 Node 请求、body 篡改、过期 timestamp、Webhook SSRF、未授权 mailbox、未授权 Cloudflare Account、超大邮件和超大附件。
## Phase 11 验收
必须完成:Cloudflare OAuth -> 自动 Node 安装 -> Domain 配置 -> Mailbox 创建 -> 真实 Email -> Email Worker -> Node -> Console -> Database/Webhook/API/MCP -> TTL 自动删除。
如果正常流程要求用户手工登录服务器执行安装命令,则不符合设计目标。
+173
View File
@@ -0,0 +1,173 @@
-- WmailHub D1 初始schema
-- Console 运行时也会在本地模式下自动执行等价初始化(幂等)
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- 节点(转发平面)
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
cloudflare_account_id TEXT,
credential_id TEXT,
status TEXT NOT NULL DEFAULT 'pending_oauth', -- pending_oauth | provisioning | offline | online | revoked
version TEXT,
last_seen_at TEXT,
ingress_url TEXT,
ingress_secret_enc TEXT,
enrollment_token TEXT,
enrollment_expires_at TEXT,
provision_steps TEXT, -- JSON: 各安装步骤状态
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 节点凭据:secret_hash 为摘要,secret_enc 为 AES-GCM 密文(HMAC 验签需要原文,加密存储防拖库泄露)
CREATE TABLE IF NOT EXISTS node_credentials (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
secret_hash TEXT NOT NULL,
secret_enc TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- active | revoked
created_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_node_credentials_node ON node_credentials(node_id);
CREATE TABLE IF NOT EXISTS domains (
id TEXT PRIMARY KEY,
cloudflare_account_id TEXT NOT NULL,
node_id TEXT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | active | error
worker_id TEXT,
zone_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(cloudflare_account_id, name)
);
CREATE TABLE IF NOT EXISTS mailboxes (
id TEXT PRIMARY KEY,
domain_id TEXT NOT NULL,
address TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active', -- active | disabled
retention_seconds INTEGER, -- NULL = 使用全局默认 86400
is_catch_all INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mailboxes_domain ON mailboxes(domain_id);
CREATE TABLE IF NOT EXISTS emails (
id TEXT PRIMARY KEY,
node_id TEXT,
mailbox_id TEXT,
message_id TEXT,
from_address TEXT,
to_addresses TEXT, -- JSON array
cc_addresses TEXT, -- JSON array
subject TEXT,
text_body TEXT,
html_body TEXT,
raw_storage_key TEXT,
size INTEGER NOT NULL DEFAULT 0,
received_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_emails_expires ON emails(expires_at);
CREATE INDEX IF NOT EXISTS idx_emails_mailbox ON emails(mailbox_id);
CREATE INDEX IF NOT EXISTS idx_emails_received ON emails(received_at DESC);
CREATE INDEX IF NOT EXISTS idx_emails_message_id ON emails(message_id);
CREATE TABLE IF NOT EXISTS email_attachments (
id TEXT PRIMARY KEY,
email_id TEXT NOT NULL,
filename TEXT,
content_type TEXT,
size INTEGER NOT NULL DEFAULT 0,
storage_key TEXT,
content_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_attachments_email ON email_attachments(email_id);
CREATE TABLE IF NOT EXISTS webhooks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
endpoint TEXT NOT NULL,
secret TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS webhook_deliveries (
id TEXT PRIMARY KEY,
webhook_id TEXT NOT NULL,
email_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | success | failed | exhausted
attempt_count INTEGER NOT NULL DEFAULT 0,
next_retry_at TEXT,
last_error TEXT,
delivered_at TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_deliveries_retry ON webhook_deliveries(status, next_retry_at);
CREATE INDEX IF NOT EXISTS idx_deliveries_webhook ON webhook_deliveries(webhook_id);
CREATE TABLE IF NOT EXISTS oauth_connections (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
account_id TEXT,
encrypted_access_token TEXT NOT NULL,
refresh_token TEXT,
expires_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- OAuth CSRF state(node_id 记录本次授权为哪个节点安装)
CREATE TABLE IF NOT EXISTS oauth_states (
state TEXT PRIMARY KEY,
node_id TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
-- Node 请求 nonce 防重放(唯一约束即拒绝重复)
CREATE TABLE IF NOT EXISTS nonces (
nonce TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
expires_at TEXT NOT NULL
);
-- Console API / MCP 凭据(wmh_ 开头,总长 32;库中只存 SHA-256)
CREATE TABLE IF NOT EXISTS api_credentials (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- api | mcp
name TEXT,
key_hash TEXT NOT NULL UNIQUE,
key_last4 TEXT NOT NULL,
created_at TEXT NOT NULL,
revoked_at TEXT,
last_used_at TEXT
);
-- 本地 sim 模式:模拟 Cloudflare API 已创建的资源,供 UI 展示安装结果
CREATE TABLE IF NOT EXISTS cf_sim_resources (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- email_worker | routing_rule | zone | account
ref_id TEXT,
payload TEXT,
created_at TEXT NOT NULL
);
+247
View File
@@ -0,0 +1,247 @@
/**
* WmailHub Node —— 纯后端邮件转发节点(docs/03、docs/05 Phase 5)。
*
* 职责(仅此而已):
* Email Worker --ingress secret--> Node --HMAC 签名--> Console
* - 校验 Worker 请求(ingress secret)
* - 转发到 Console(有限重试:网络错误/5xx 最多 3 次;401/403 不重试)
* - 心跳上报(60s,可配置)
* - bootstrap:支持 enrollment token 自动换取身份,secret 落盘 0600
* - 结构化 JSON 日志、优雅退出
*
* 配置来源(优先级):环境变量 > config.json(bootstrap 写入)
* WMAILHUB_CONSOLE_URL Console 地址
* WMAILHUB_NODE_ID 节点 ID
* WMAILHUB_NODE_SECRET node credential secret
* WMAILHUB_INGRESS_SECRET Worker -> Node 共享密钥
* WMAILHUB_ENROLLMENT_TOKEN 首次启动用 token 换身份(可选)
* WMAILHUB_PORT 监听端口(默认 18788)
*/
import type { Server } from "bun";
import path from "node:path";
import {
signNodeRequest,
INGRESS_SECRET_HEADER,
NODE_HEARTBEAT_INTERVAL_MS,
} from "../../shared/src/index.ts";
import { APP_VERSION } from "./version.ts";
/** # 成员变量: Node 运行配置 **/
interface NodeConfig {
consoleUrl: string;
nodeId: string;
nodeSecret: string;
ingressSecret: string;
port: number;
}
/** # 成员变量: config.json 落盘位置 **/
const CONFIG_FILE = path.resolve(import.meta.dir, "../config.json");
/** # 函数
* 结构化 JSON 日志
*/
function log(lvl: "info" | "warn" | "error", msg: string, extra: Record<string, unknown> = {}) {
console.log(JSON.stringify({ t: new Date().toISOString(), lvl, msg, ...extra }));
}
/** # 函数
* 加载配置:环境变量优先,其次 config.json(enrollment 产物)
*/
async function loadConfig(): Promise<NodeConfig> {
let file: Partial<NodeConfig> = {};
try {
file = (await Bun.file(CONFIG_FILE).json()) as Partial<NodeConfig>;
} catch {
// 无 config.json,纯环境变量模式
}
const env = process.env;
const consoleUrl = env.WMAILHUB_CONSOLE_URL ?? file.consoleUrl;
const ingressSecret = env.WMAILHUB_INGRESS_SECRET ?? file.ingressSecret;
let nodeId = env.WMAILHUB_NODE_ID ?? file.nodeId;
let nodeSecret = env.WMAILHUB_NODE_SECRET ?? file.nodeSecret;
if (!consoleUrl || !ingressSecret) {
console.error("missing WMAILHUB_CONSOLE_URL / WMAILHUB_INGRESS_SECRET");
process.exit(1);
}
// bootstrap enrollment:有 token 而无身份时自动换取并落盘(0600)
const enrollmentToken = env.WMAILHUB_ENROLLMENT_TOKEN ?? "";
if ((!nodeId || !nodeSecret) && enrollmentToken) {
log("info", "enrolling with console");
const resp = await fetch(new URL("/api/internal/enroll", consoleUrl), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enrollment_token: enrollmentToken }),
});
if (!resp.ok) {
log("error", "enroll failed", { status: resp.status });
process.exit(1);
}
const data = (await resp.json()) as {
node_id: string;
credential_id: string;
credential_secret: string;
ingress_secret: string;
};
nodeId = data.node_id;
nodeSecret = data.credential_secret;
await Bun.write(CONFIG_FILE, JSON.stringify({ consoleUrl, nodeId, nodeSecret, ingressSecret: ingressSecret }), {
mode: 0o600,
});
log("info", "enrolled, secure config written", { node_id: nodeId, credential_id: data.credential_id });
}
if (!nodeId || !nodeSecret) {
console.error("missing WMAILHUB_NODE_ID / WMAILHUB_NODE_SECRET (or WMAILHUB_ENROLLMENT_TOKEN)");
process.exit(1);
}
return {
consoleUrl: consoleUrl.replace(/\/$/, ""),
nodeId,
nodeSecret,
ingressSecret,
port: Number(env.WMAILHUB_PORT ?? 18788),
};
}
/** # 函数
* 转发邮件到 Console:构造 HMAC 签名请求;网络错误/5xx/429 有限重试,401/403 立即放弃
*/
async function forwardToConsole(cfg: NodeConfig, raw: Uint8Array, mailFrom: string, rcptTo: string): Promise<Response> {
const url = `${cfg.consoleUrl}/api/internal/nodes/${cfg.nodeId}/emails`;
const maxAttempts = 3;
let lastError = "";
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const headers = await signNodeRequest(cfg.nodeSecret, cfg.nodeId, "POST", url, raw);
headers["Content-Type"] = "message/rfc822";
headers["X-WmailHub-Mail-From"] = mailFrom;
headers["X-WmailHub-Rcpt-To"] = rcptTo;
try {
const resp = await fetch(url, {
method: "POST",
headers,
body: raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength) as ArrayBuffer,
});
if (resp.ok) return resp;
if (resp.status === 401 || resp.status === 403) {
// 凭据问题:重试无意义(docs/04:不得无限重试)
return resp;
}
lastError = `console responded ${resp.status}`;
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
if (attempt < maxAttempts) {
const delayMs = 500 * 2 ** (attempt - 1);
log("warn", "forward retry scheduled", { attempt, delayMs, error: lastError });
await new Promise((r) => setTimeout(r, delayMs));
}
}
return new Response(JSON.stringify({ error: lastError }), { status: 503 });
}
/** # 函数
* 发送一次心跳;401/403 只告警不退出
*/
async function heartbeat(cfg: NodeConfig): Promise<void> {
const url = `${cfg.consoleUrl}/api/internal/nodes/${cfg.nodeId}/heartbeat`;
const body = new TextEncoder().encode(JSON.stringify({ version: APP_VERSION }));
const headers = await signNodeRequest(cfg.nodeSecret, cfg.nodeId, "POST", url, body);
headers["Content-Type"] = "application/json";
try {
const resp = await fetch(url, { method: "POST", headers, body });
if (!resp.ok) {
log("warn", "heartbeat rejected", { status: resp.status });
} else {
log("info", "heartbeat ok");
}
} catch (err) {
log("warn", "heartbeat failed", { error: err instanceof Error ? err.message : String(err) });
}
}
/** # 函数
* 主入口:加载配置 -> 起服务 -> 定时心跳 -> 优雅退出
*/
async function main() {
const cfg = await loadConfig();
let shuttingDown = false;
let inflight = 0;
const server: Server<unknown> = Bun.serve({
port: cfg.port,
hostname: "0.0.0.0",
idleTimeout: 30,
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/__health") {
return Response.json({ ok: true, node_id: cfg.nodeId, version: APP_VERSION });
}
// 邮件 ingress:仅接受 Email Worker 的签名 secret 请求
if (request.method === "POST" && url.pathname === "/ingress/email") {
if (request.headers.get(INGRESS_SECRET_HEADER) !== cfg.ingressSecret) {
log("warn", "ingress rejected: bad secret", { ip: request.headers.get("x-forwarded-for") ?? "" });
return Response.json({ error: "forbidden" }, { status: 403 });
}
const raw = new Uint8Array(await request.arrayBuffer());
if (raw.byteLength === 0) {
return Response.json({ error: "empty body" }, { status: 400 });
}
const mailFrom = request.headers.get("X-WmailHub-Mail-From") ?? "";
const rcptTo = request.headers.get("X-WmailHub-Rcpt-To") ?? "";
inflight++;
try {
const resp = await forwardToConsole(cfg, raw, mailFrom, rcptTo);
const body = (await resp.json().catch(() => ({}))) as Record<string, unknown>;
log(resp.ok ? "info" : "error", resp.ok ? "email forwarded" : "forward failed", {
bytes: raw.byteLength,
status: resp.status,
email_id: body.id,
});
return Response.json(body, { status: resp.status });
} finally {
inflight--;
}
}
return Response.json({ error: "not found" }, { status: 404 });
},
});
log("info", "node started", {
node_id: cfg.nodeId,
port: cfg.port,
console: cfg.consoleUrl,
version: APP_VERSION,
});
// 心跳(启动即一次)
void heartbeat(cfg);
const heartbeatTimer = setInterval(() => void heartbeat(cfg), NODE_HEARTBEAT_INTERVAL_MS);
/** 优雅退出:停止接新请求,等在途转发完成 */
async function shutdown(signal: string) {
if (shuttingDown) return;
shuttingDown = true;
log("info", "node shutting down", { signal, inflight });
clearInterval(heartbeatTimer);
server.stop(false); // 停止接受新连接,保持既有
const deadline = Date.now() + 10_000;
while (inflight > 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 100));
}
server.stop(true);
process.exit(0);
}
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
}
main().catch((err) => {
log("error", "node crashed", { error: err instanceof Error ? err.stack : String(err) });
process.exit(1);
});
+4
View File
@@ -0,0 +1,4 @@
/** Node 版本:与项目根 package.json 一致(全局版本规范,禁止手写常量) */
import pkg from "../../package.json";
export const APP_VERSION: string = pkg.version;
+32
View File
@@ -0,0 +1,32 @@
{
"name": "wmailhub",
"version": "1.0.0",
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console + Node + Email Worker)",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run scripts/dev.ts",
"build": "bun run scripts/build-frontend.ts",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ant-design/icons": "^6.3.4",
"antd": "^6.6.5",
"hono": "^4.9.0",
"postal-mime": "^2.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"bun-types": "^1.2.0",
"playwright-core": "^1.63.0",
"typescript": "^5.9.0"
},
"engines": {
"bun": ">=1.2.0"
}
}
+36
View File
@@ -0,0 +1,36 @@
/**
* 前端构建:bun build 打包 React SPA → console/public/
* 产物:index.html + assets/main.js + logo/favicon。
*/
import { cpSync, mkdirSync } from "node:fs";
import path from "node:path";
const ROOT = path.resolve(import.meta.dir, "..");
const FRONTEND = path.join(ROOT, "console/frontend");
const PUBLIC = path.join(ROOT, "console/public");
mkdirSync(path.join(PUBLIC, "assets"), { recursive: true });
const result = await Bun.build({
entrypoints: [path.join(FRONTEND, "src/main.tsx")],
outdir: path.join(PUBLIC, "assets"),
naming: "[name].[ext]",
target: "browser",
minify: true,
splitting: false,
sourcemap: process.env.WMAILHUB_DEV ? "linked" : "none",
});
if (!result.success) {
for (const log of result.logs) console.error(log);
console.error("[build] frontend build failed");
process.exit(1);
}
cpSync(path.join(FRONTEND, "index.html"), path.join(PUBLIC, "index.html"));
cpSync(path.join(ROOT, "assets/logo.svg"), path.join(PUBLIC, "logo.svg"));
cpSync(path.join(ROOT, "assets/favicon.svg"), path.join(PUBLIC, "favicon.svg"));
cpSync(path.join(ROOT, "assets/favicon.ico"), path.join(PUBLIC, "favicon.ico"));
console.log("[build] frontend -> console/public OK");
+179
View File
@@ -0,0 +1,179 @@
/**
* 本地开发一键启动:console + node + email worker。
* bun run dev # 常规启动(复用既有数据)
* bun run dev -- --reset # 清空本地数据后全新安装
*
* 流程:
* 1. 构建前端
* 2. 启动 Console(:18787,自动迁移 + admin/admin 种子)
* 3. 确保存在本地 Node(无则创建)并完成 sim 模式 OAuth 自动安装(幂等)
* 4. 启动 Node(:18788,enrollment 换取身份,config.json 0600 落盘)
* 5. 启动 Email Worker(:18789,转发到 Node)
* 6. Ctrl-C 一起退出
*/
const ROOT = new URL("..", import.meta.url).pathname;
const CONSOLE_PORT = 18787;
const NODE_PORT = 18788;
const WORKER_PORT = 18789;
const BASE = `http://127.0.0.1:${CONSOLE_PORT}`;
const reset = process.argv.includes("--reset");
if (reset) {
await Bun.$`rm -rf ${ROOT}console/data ${ROOT}node/config.json`.quiet();
console.log("[dev] 本地数据已重置");
}
/** 前端构建(产物已存在时可跳过:SKIP_BUILD=1) */
if (!process.env.SKIP_BUILD) {
console.log("[dev] 构建前端...");
await Bun.$`bun run build`.cwd(ROOT);
}
interface Child {
name: string;
proc: Bun.Subprocess<"ignore", "inherit", "inherit">;
}
const children: Child[] = [];
/** 启动子进程并登记 */
function spawn(name: string, args: string[], cwd: string, env?: Record<string, string>) {
const proc = Bun.spawn(["bun", ...args], {
cwd,
env: { ...process.env, ...env },
stdout: "inherit",
stderr: "inherit",
stdin: "ignore",
});
children.push({ name, proc });
return proc;
}
/** 等待 HTTP 就绪 */
async function waitReady(url: string, label: string, tries = 60): Promise<void> {
for (let i = 0; i < tries; i++) {
try {
const resp = await fetch(url);
if (resp.ok) return;
} catch {
// not ready
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`${label} 未就绪:${url}`);
}
// 1. Console
spawn("console", ["console/src/local/main.ts"], ROOT);
await waitReady(`${BASE}/api/health`, "console");
console.log("[dev] console ready");
// 2. 登录拿会话
const loginResp = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "admin" }),
});
if (!loginResp.ok) throw new Error("admin 登录失败");
const cookie = (loginResp.headers.get("set-cookie") ?? "").split(";")[0];
const authHeaders = { Cookie: cookie, "Content-Type": "application/json" };
// 3. 确保本地 Node 存在
const nodesResp = await fetch(`${BASE}/api/nodes`, { headers: authHeaders });
const existing = ((await nodesResp.json()) as { items: { id: string; name: string }[] }).items;
let nodeId: string;
let ingressSecret: string;
let enrollmentToken: string | null = null;
if (existing.length > 0) {
nodeId = existing[0].id;
ingressSecret = ((await (await fetch(`${BASE}/api/nodes/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret;
// 无论 config.json 是否存在,都发一个新 token:有身份的 Node 会忽略它,无身份的用它 enrollment
const rotated = (await (
await fetch(`${BASE}/api/nodes/${nodeId}/rotate`, { method: "POST", headers: authHeaders })
).json()) as { enrollment_token: string };
enrollmentToken = rotated.enrollment_token;
console.log(`[dev] 复用既有节点 ${nodeId}`);
} else {
const created = (await (
await fetch(`${BASE}/api/nodes`, {
method: "POST",
headers: authHeaders,
body: JSON.stringify({ name: "Local Node", ingress_url: `http://127.0.0.1:${NODE_PORT}/ingress/email` }),
})
).json()) as { node_id: string; enrollment_token: string; ingress_secret: string };
nodeId = created.node_id;
ingressSecret = created.ingress_secret;
enrollmentToken = created.enrollment_token;
console.log(`[dev] 已创建节点 ${nodeId}`);
}
// 4. sim OAuth 自动安装(幂等;节点已 online 时跳过)
const nodeDetail = (await (await fetch(`${BASE}/api/nodes/${nodeId}`, { headers: authHeaders })).json()) as {
node: { status: string };
steps: Record<string, { status: string }>;
};
if (nodeDetail.node.status === "pending_oauth") {
const startResp = await fetch(`${BASE}/api/oauth/cloudflare/start?node_id=${nodeId}`, { headers: authHeaders });
const { state } = (await startResp.json()) as { state: string };
const approve = await fetch(`${BASE}/api/oauth/cloudflare/sim/approve`, {
method: "POST",
headers: authHeaders,
body: JSON.stringify({ state }),
});
const approveRes = (await approve.json()) as { ok: boolean; error?: string };
if (!approveRes.ok) throw new Error(`自动安装失败:${approveRes.error}`);
console.log("[dev] Cloudflare OAuth(sim)自动安装完成:Email Worker / Routing / Domain / Mailbox 就绪");
} else {
console.log(`[dev] 节点已安装(status=${nodeDetail.node.status}),跳过安装`);
}
// 5. Node(首次用 enrollment token 换身份;之后 config.json 已有身份)
const nodeEnv: Record<string, string> = {
WMAILHUB_CONSOLE_URL: BASE,
WMAILHUB_INGRESS_SECRET: ingressSecret,
WMAILHUB_PORT: String(NODE_PORT),
};
if (enrollmentToken) nodeEnv.WMAILHUB_ENROLLMENT_TOKEN = enrollmentToken;
spawn("node", ["node/src/main.ts"], ROOT, nodeEnv);
await waitReady(`http://127.0.0.1:${NODE_PORT}/__health`, "node");
console.log("[dev] node ready");
// 6. Email Worker
spawn("worker", ["worker/src/local.ts"], ROOT, {
NODE_INGRESS_URL: `http://127.0.0.1:${NODE_PORT}/ingress/email`,
NODE_INGRESS_SECRET: ingressSecret,
WMAILHUB_WORKER_PORT: String(WORKER_PORT),
});
await waitReady(`http://127.0.0.1:${WORKER_PORT}/__health`, "email worker");
console.log("[dev] email worker ready");
console.log("");
console.log("======== WmailHub 本地环境已就绪 ========");
console.log(` Console : ${BASE} (admin / admin)`);
console.log(` Node : http://127.0.0.1:${NODE_PORT}/__health`);
console.log(` Email Worker: http://127.0.0.1:${WORKER_PORT}/__health`);
console.log(" 发测试邮件 : curl -X POST http://127.0.0.1:18789/__email_test \\");
console.log(" -H 'X-WmailHub-Ingress-Secret: <secret>' \\");
console.log(" -H 'X-WmailHub-Test-To: hello@example.test' --data-binary @mail.eml");
console.log("==========================================");
/** 退出时统一收割子进程 */
function shutdown() {
for (const { name, proc } of children) {
proc.kill();
console.log(`[dev] ${name} stopped`);
}
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// 任一子进程退出则整体退出
for (const { name, proc } of children) {
void proc.exited.then((code) => {
console.error(`[dev] ${name} exited with ${code}`);
shutdown();
});
}
+39
View File
@@ -0,0 +1,39 @@
/** WmailHub 全局常量:邮件大小限制、TTL、Webhook 策略、签名头名称 */
/** # 成员变量: 默认邮件保留时长(秒),1 天 **/
export const DEFAULT_RETENTION_SECONDS = 86_400;
/** # 成员变量: 单封邮件最大字节数(raw MIME),Cloudflare Email Worker 上限为 25MB,取保守值 20MB **/
export const MAX_EMAIL_BYTES = 20 * 1024 * 1024;
/** # 成员变量: 单封邮件最大附件数 **/
export const MAX_ATTACHMENTS = 20;
/** # 成员变量: 单个附件最大字节数 **/
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
/** # 成员变量: MIME 头最大字节数 **/
export const MAX_MIME_HEADER_BYTES = 256 * 1024;
/** # 成员变量: Webhook 单次投递超时(毫秒) **/
export const WEBHOOK_TIMEOUT_MS = 10_000;
/** # 成员变量: Webhook 最大尝试次数 **/
export const WEBHOOK_MAX_ATTEMPTS = 5;
/** # 成员变量: Webhook 首次重试退避(毫秒),之后指数递增 **/
export const WEBHOOK_RETRY_BASE_MS = 60_000;
/** # 成员变量: Webhook 签名头 **/
export const WEBHOOK_HEADERS = {
event: "X-WmailHub-Event",
delivery: "X-WmailHub-Delivery",
timestamp: "X-WmailHub-Timestamp",
signature: "X-WmailHub-Signature",
} as const;
/** # 成员变量: Worker -> Node ingress 共享密钥请求头 **/
export const INGRESS_SECRET_HEADER = "X-WmailHub-Ingress-Secret";
/** # 成员变量: Node 心跳间隔(毫秒) **/
export const NODE_HEARTBEAT_INTERVAL_MS = 60_000;
+149
View File
@@ -0,0 +1,149 @@
/**
* WmailHub 共享工具:哈希 / HMAC / 密码哈希 / AES-GCM 加密
* 全部基于 WebCrypto,兼容 Cloudflare Workers 与 Bun 本地运行时。
*/
const encoder = new TextEncoder();
/** # 函数
* 计算 SHA-256,返回 hex 字符串
* 参数: data - 字符串或二进制数据
* 返回值: 64 位 hex 小写
*/
export async function sha256Hex(data: string | Uint8Array | ArrayBuffer): Promise<string> {
const buf = typeof data === "string" ? encoder.encode(data) : data;
const digest = await crypto.subtle.digest("SHA-256", buf as ArrayBuffer);
return toHex(new Uint8Array(digest));
}
/** # 函数
* 计算 HMAC-SHA256,返回 hex 字符串
* 参数: secret - 密钥(字符串);message - 被签名内容
* 返回值: hex 小写签名
*/
export async function hmacSha256Hex(secret: string, message: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
return toHex(new Uint8Array(sig));
}
/** # 函数
* PBKDF2-SHA256 派生密钥(用于用户密码哈希)
* 参数: password - 明文密码;salt - 盐(hex);iterations - 迭代次数
* 返回值: 派生密钥 hex
*/
async function pbkdf2(password: string, salt: string, iterations: number): Promise<string> {
const baseKey = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, [
"deriveBits",
]);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt: hexToBytes(salt), iterations },
baseKey,
256,
);
return toHex(new Uint8Array(bits));
}
/** # 函数
* 生成用户密码哈希,格式 pbkdf2$<iterations>$<saltHex>$<hashHex>
* 参数: password - 明文密码
* 返回值: 可存库的哈希串
*/
export async function hashPassword(password: string): Promise<string> {
const iterations = 100_000;
const salt = toHex(crypto.getRandomValues(new Uint8Array(16)));
const hash = await pbkdf2(password, salt, iterations);
return `pbkdf2$${iterations}$${salt}$${hash}`;
}
/** # 函数
* 校验用户密码(常数时间比较)
* 参数: password - 待校验明文;stored - 库中哈希串
* 返回值: 是否匹配
*/
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const parts = stored.split("$");
if (parts.length !== 4 || parts[0] !== "pbkdf2") return false;
const iterations = Number(parts[1]);
if (!Number.isInteger(iterations) || iterations <= 0) return false;
const hash = await pbkdf2(password, parts[2], iterations);
return timingSafeEqualHex(hash, parts[3]);
}
/** # 函数
* 由应用主密钥派生 AES-256 密钥(用于 OAuth token 等加密存储)
* 参数: masterSecret - 环境提供的主密钥字符串
* 返回值: 可用于 crypto.subtle 的 AES-GCM CryptoKey
*/
export async function deriveAesKey(masterSecret: string): Promise<CryptoKey> {
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(`wmailhub:${masterSecret}`));
return crypto.subtle.importKey("raw", digest, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
}
/** # 函数
* AES-256-GCM 加密,输出 base64(iv || ciphertext)
* 参数: key - deriveAesKey 的产物;plaintext - 明文
* 返回值: base64 密文
*/
export async function aesEncrypt(key: CryptoKey, plaintext: string): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(plaintext));
const merged = new Uint8Array(iv.length + ct.byteLength);
merged.set(iv);
merged.set(new Uint8Array(ct), iv.length);
return bytesToBase64(merged);
}
/** # 函数
* AES-256-GCM 解密(aesEncrypt 的逆操作)
* 参数: key - deriveAesKey 的产物;blob - base64 密文
* 返回值: 明文;损坏时抛错
*/
export async function aesDecrypt(key: CryptoKey, blob: string): Promise<string> {
const merged = base64ToBytes(blob);
const iv = merged.slice(0, 12);
const ct = merged.slice(12);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
return new TextDecoder().decode(pt);
}
/** # 函数
* 常数时间 hex 字符串比较,防止时序侧信道
*/
export function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
export function toHex(bytes: Uint8Array): string {
let out = "";
for (const b of bytes) out += b.toString(16).padStart(2, "0");
return out;
}
export function hexToBytes(hex: string): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(new ArrayBuffer(hex.length / 2));
for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
export function bytesToBase64(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
export function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
+70
View File
@@ -0,0 +1,70 @@
/**
* 部署到 Cloudflare 的 Email Worker 脚本模板。
*
* Console 在自动安装 Node 时,通过 Cloudflare API 把该脚本上传为
* Email Worker,并用占位符替换注入 Node ingress 地址与 ingress secret。
* worker/(本地开发版)与该模板保持同一转发逻辑。
*/
export const EMAIL_WORKER_SCRIPT = String.raw`// WmailHub Email Worker (auto-generated by Console provisioning)
// Cloudflare Email Routing -> Email Worker -> Node ingress
const INGRESS_URL = "__NODE_INGRESS_URL__";
const INGRESS_SECRET = "__NODE_INGRESS_SECRET__";
export default {
async email(message, env, ctx) {
const raw = await new Response(message.raw).arrayBuffer();
await forward(raw, message.from, message.to);
},
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/__health") {
return Response.json({ ok: true, service: "wmailhub-email-worker" });
}
// 本地/联调测试入口:与 email handler 走同一条转发链路
if (request.method === "POST" && url.pathname === "/__email_test") {
if (request.headers.get("X-WmailHub-Ingress-Secret") !== INGRESS_SECRET) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const from = request.headers.get("X-WmailHub-Test-From") || "sender@example.org";
const to = request.headers.get("X-WmailHub-Test-To") || "hello@example.com";
const raw = await request.arrayBuffer();
try {
await forward(raw, from, to);
return Response.json({ ok: true });
} catch (err) {
return Response.json({ error: String(err) }, { status: 502 });
}
}
return Response.json({ error: "not found" }, { status: 404 });
},
};
async function forward(raw, from, to) {
const resp = await fetch(INGRESS_URL, {
method: "POST",
headers: {
"Content-Type": "message/rfc822",
"X-WmailHub-Ingress-Secret": INGRESS_SECRET,
"X-WmailHub-Mail-From": from,
"X-WmailHub-Rcpt-To": to,
},
body: raw,
});
if (!resp.ok) {
throw new Error("node ingress responded " + resp.status);
}
}
`;
/** # 函数
* 用实际 Node ingress 配置渲染 Email Worker 脚本
* 参数: ingressUrl - Node 的 Email Worker ingress 完整 URL;ingressSecret - Worker -> Node 共享密钥
* 返回值: 可直接上传 Cloudflare 的脚本字符串
*/
export function renderEmailWorkerScript(ingressUrl: string, ingressSecret: string): string {
return EMAIL_WORKER_SCRIPT.replace("__NODE_INGRESS_URL__", ingressUrl).replace(
"__NODE_INGRESS_SECRET__",
ingressSecret,
);
}
+37
View File
@@ -0,0 +1,37 @@
/**
* WmailHub ID 生成:全部带业务前缀,便于日志与 API 识别。
*/
/** # 函数
* 生成 16 位 hex 随机串
*/
function randomHex(bytes: number): string {
const buf = crypto.getRandomValues(new Uint8Array(bytes));
let out = "";
for (const b of buf) out += b.toString(16).padStart(2, "0");
return out;
}
/** # 函数
* 生成带前缀的业务 ID,例如 node_a1b2c3d4e5f60718
* 参数: prefix - 业务前缀(node/dom/mbx/msg/att/whk/dlv/oauth/key/state/sim)
*/
export function newId(prefix: string): string {
return `${prefix}_${randomHex(8)}`;
}
export const ids = {
user: () => newId("usr"),
node: () => newId("node"),
domain: () => newId("dom"),
mailbox: () => newId("mbx"),
email: () => newId("msg"),
attachment: () => newId("att"),
webhook: () => newId("whk"),
delivery: () => newId("dlv"),
oauth: () => newId("oauth"),
apiKey: () => newId("key"),
oauthState: () => newId("st"),
credential: () => newId("cred"),
simResource: () => newId("sim"),
};
+8
View File
@@ -0,0 +1,8 @@
/** WmailHub 共享库出口:Console / Node / Email Worker / 测试共用 */
export * from "./crypto.ts";
export * from "./protocol.ts";
export * from "./keys.ts";
export * from "./ids.ts";
export * from "./ssrf.ts";
export * from "./email-worker-template.ts";
export * from "./constants.ts";
+54
View File
@@ -0,0 +1,54 @@
/**
* WmailHub Console API / MCP Key 生成与校验
*
* 规则:Key 以 `wmh_` 开头,后接随机字符串,总长度 32 位
* (即 `wmh_` + 28 位随机 [A-Za-z0-9])。
*/
/** # 成员变量: Key 前缀 **/
export const KEY_PREFIX = "wmh_";
/** # 成员变量: Key 总长度(含前缀) **/
export const KEY_TOTAL_LENGTH = 32;
/** # 成员变量: Key 随机部分字母表 **/
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
/** # 函数
* 生成随机 token(拒绝采样避免模偏差)
* 参数: length - 随机串长度
* 返回值: 随机字符串
*/
export function randomToken(length: number): string {
let out = "";
while (out.length < length) {
const bytes = crypto.getRandomValues(new Uint8Array(length * 2));
for (const b of bytes) {
if (b >= 248) continue; // 248 = 62 * 4,超出可整除区间则丢弃重采样
out += ALPHABET[b % ALPHABET.length];
if (out.length === length) break;
}
}
return out;
}
/** # 函数
* 生成 Console API / MCP Key(wmh_ 前缀,总长 32)
* 返回值: 形如 wmh_XXXXXXXXXXXXXXXXXXXXXXXXXXXX 的明文 Key(仅在创建时展示一次)
*/
export function generateWmailKey(): string {
return KEY_PREFIX + randomToken(KEY_TOTAL_LENGTH - KEY_PREFIX.length);
}
/** # 函数
* 校验 Key 格式是否符合 wmh_ + 28 位随机串、总长 32 的规范
*/
export function isValidWmailKey(key: string): boolean {
if (typeof key !== "string") return false;
if (key.length !== KEY_TOTAL_LENGTH) return false;
if (!key.startsWith(KEY_PREFIX)) return false;
for (const ch of key.slice(KEY_PREFIX.length)) {
if (!ALPHABET.includes(ch)) return false;
}
return true;
}
+169
View File
@@ -0,0 +1,169 @@
/**
* WmailHub Node <-> Console 通信协议(协议版本 1)
*
* 签名规则(docs/04_NODE_PROTOCOL.md):
* body_hash = SHA256(raw_body)
* canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + body_hash
* signature = HMAC-SHA256(credential_secret, canonical)
*
* Console 端拒绝:timestamp 超窗(默认 5 分钟)、重复 nonce、错误 signature、
* 错误 body hash、已撤销 credential。
*/
import { hmacSha256Hex, sha256Hex, timingSafeEqualHex } from "./crypto.ts";
/** # 成员变量: 当前协议版本号,随请求头 X-WmailHub-Protocol 传递 **/
export const PROTOCOL_VERSION = "1";
/** # 成员变量: timestamp 允许窗口(秒),默认 5 分钟 **/
export const TIMESTAMP_WINDOW_SECONDS = 300;
/** # 函数
* 计算请求体的 SHA-256 hex(body hash)
*/
export async function bodyHash(body: Uint8Array | string): Promise<string> {
return sha256Hex(body);
}
/** # 成员变量: 签名参与要素 **/
export interface SignatureComponents {
/** HTTP 方法,大写 **/
method: string;
/** 请求路径(不含 query) **/
path: string;
/** unix 秒级时间戳字符串 **/
timestamp: string;
/** 一次性随机串 **/
nonce: string;
/** 请求体 SHA-256 hex **/
bodyHash: string;
}
/** # 函数
* 拼接待签名的 canonical 字符串
*/
export function canonicalString(c: SignatureComponents): string {
return [c.method.toUpperCase(), c.path, c.timestamp, c.nonce, c.bodyHash].join("\n");
}
/** # 函数
* 计算 HMAC-SHA256 签名 hex
*/
export async function computeSignature(secret: string, c: SignatureComponents): Promise<string> {
return hmacSha256Hex(secret, canonicalString(c));
}
/** # 函数
* Node 端:为发往 Console 的请求生成全部签名请求头
* 参数: secret - node credential secret;nodeId - 节点 ID;method/url 同 HTTP 语义;body - 原始请求体
* 返回值: 可直接放入 fetch headers 的对象
*/
export async function signNodeRequest(
secret: string,
nodeId: string,
method: string,
url: string,
body: Uint8Array,
): Promise<Record<string, string>> {
const u = new URL(url);
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomNonce();
const bh = await bodyHash(body);
const signature = await computeSignature(secret, {
method: method.toUpperCase(),
path: u.pathname,
timestamp,
nonce,
bodyHash: bh,
});
return {
"Authorization": `WmailHub-Node ${nodeId}`,
"X-WmailHub-Timestamp": timestamp,
"X-WmailHub-Nonce": nonce,
"X-WmailHub-Body-SHA256": bh,
"X-WmailHub-Signature": signature,
"X-WmailHub-Protocol": PROTOCOL_VERSION,
};
}
/** # 成员变量: verifyNodeRequest 的输入 **/
export interface VerifyNodeRequestInput {
/** 请求方声明的 credential secret(Console 从库中按 node_id 取出) **/
secret: string;
/** HTTP 方法 **/
method: string;
/** 完整 URL 或路径(用于取 pathname) **/
url: string;
/** 原始请求体 **/
body: Uint8Array;
/** 请求头(大小写不敏感,由调用方传入 Headers 或普通对象) **/
headers: Headers;
/** 当前 unix 秒,测试可注入 **/
now?: number;
}
/** # 成员变量: verifyNodeRequest 的结果 **/
export type VerifyNodeResult =
| { ok: true; timestamp: number; nonce: string }
| { ok: false; status: 400 | 401; error: string };
/** # 函数
* Console 端:校验 Node 请求头签名完整性(不含 nonce 防重放与 credential 撤销检查,
* 这两项由调用方结合数据库完成:nonce 用唯一索引入库,credential 状态查询后拒绝)。
*/
export async function verifyNodeRequest(input: VerifyNodeRequestInput): Promise<VerifyNodeResult> {
const h = input.headers;
const auth = h.get("Authorization") ?? "";
const m = /^WmailHub-Node\s+(.+)$/.exec(auth);
if (!m) return { ok: false, status: 401, error: "missing node authorization" };
const timestamp = h.get("X-WmailHub-Timestamp") ?? "";
const nonce = h.get("X-WmailHub-Nonce") ?? "";
const bodySha = h.get("X-WmailHub-Body-SHA256") ?? "";
const signature = h.get("X-WmailHub-Signature") ?? "";
const protocol = h.get("X-WmailHub-Protocol") ?? "";
if (!timestamp || !nonce || !bodySha || !signature) {
return { ok: false, status: 401, error: "missing signature headers" };
}
if (protocol !== PROTOCOL_VERSION) {
return { ok: false, status: 400, error: `unsupported protocol version: ${protocol}` };
}
const ts = Number(timestamp);
const now = input.now ?? Math.floor(Date.now() / 1000);
if (!Number.isInteger(ts) || Math.abs(now - ts) > TIMESTAMP_WINDOW_SECONDS) {
return { ok: false, status: 401, error: "timestamp out of window" };
}
const actualBodyHash = await bodyHash(input.body);
if (!timingSafeEqualHex(actualBodyHash, bodySha)) {
return { ok: false, status: 401, error: "body hash mismatch" };
}
const expected = await computeSignature(input.secret, {
method: input.method.toUpperCase(),
path: new URL(input.url, "https://local").pathname,
timestamp,
nonce,
bodyHash: bodySha,
});
if (!timingSafeEqualEqual(expected, signature)) {
return { ok: false, status: 401, error: "invalid signature" };
}
return { ok: true, timestamp: ts, nonce };
}
function timingSafeEqualEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
/** # 函数
* 生成一次性的随机 nonce(32 hex)
*/
export function randomNonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
let out = "";
for (const b of bytes) out += b.toString(16).padStart(2, "0");
return out;
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Webhook endpoint SSRF 防护(docs/07、docs/08)
*
* 至少阻止:localhost、private IPv4/IPv6、link-local、云 metadata endpoint、
* 非法 scheme、以及 DNS 解析出私网 IP 的情况(DNS rebinding 的一半防御;
* 另一半由"解析后连接同一 IP"的策略约束,本地运行时直接按解析结果拦截)。
*/
/** # 成员变量: 允许的 URL scheme **/
const ALLOWED_SCHEMES = new Set(["http", "https"]);
/** # 函数
* 判断 IPv4 是否为私网/保留地址
*/
function isPrivateIPv4(ip: string): boolean {
const parts = ip.split(".").map(Number);
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
const [a, b] = parts;
if (a === 0 || a === 10 || a === 127) return true; // 本网段 / 私网 / loopback
if (a === 169 && b === 254) return true; // link-local + 云 metadata 169.254.169.254
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
if (a === 192 && b === 0) return true; // 192.0.0.x / 192.0.2.x
if (a === 198 && (b === 18 || b === 19)) return true; // benchmark
if (a >= 224) return true; // 组播 + 保留
return false;
}
/** # 函数
* 判断 IPv6 是否为 loopback/私网/link-local/IPv4 映射私网
*/
function isPrivateIPv6(raw: string): boolean {
const ip = raw.toLowerCase().replace(/^\[|\]$/g, "");
if (ip === "::" || ip === "::1") return true;
if (ip.startsWith("fe8") || ip.startsWith("fe9") || ip.startsWith("fea") || ip.startsWith("feb")) {
return true; // link-local fe80::/10
}
if (ip.startsWith("fc") || ip.startsWith("fd")) return true; // ULA fc00::/7
if (ip.startsWith("::ffff:")) {
const v4 = ip.slice(7);
if (v4.includes(".")) return isPrivateIPv4(v4);
}
if (ip.startsWith("64:ff9b:")) return true; // NAT64
if (ip.startsWith("2002:")) {
// 6to4:内嵌 IPv4
const seg = ip.slice(5);
const v4 = seg
.split(":")[0]
.match(/.{2}/g)
?.map((x) => Number.parseInt(x, 16))
.join(".");
if (v4) return isPrivateIPv4(v4);
}
return false;
}
/** # 函数
* 判断字符串是否为 IP 字面量(v4 或 v6)
*/
export function isIpLiteral(host: string): boolean {
const h = host.replace(/^\[|\]$/g, "");
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return true;
if (h.includes(":")) return true; // 含冒号即视为 IPv6
return false;
}
/** # 函数
* 校验 IP 是否允许外发
*/
export function isPublicIp(ip: string): boolean {
if (ip.includes(".") && !ip.startsWith("::ffff:") && !ip.includes(":")) return !isPrivateIPv4(ip);
return !isPrivateIPv6(ip);
}
/** # 函数
* 静态校验 webhook URL(不含 DNS 解析)
* 参数: rawUrl - 用户配置的 endpoint
* 返回值: null 表示通过;否则返回拒绝原因
*/
export function checkWebhookUrl(rawUrl: string, allowPrivateHosts = false): string | null {
let u: URL;
try {
u = new URL(rawUrl);
} catch {
return "invalid url";
}
if (!ALLOWED_SCHEMES.has(u.protocol.slice(0, -1))) return "scheme not allowed";
if (allowPrivateHosts) return null; // 本地联调开关(WMAILHUB_ALLOW_PRIVATE_WEBHOOKS=1)
const host = u.hostname.toLowerCase();
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) {
return "localhost-like host not allowed";
}
if (host === "metadata.google.internal" || host === "metadata" || host.endsWith(".internal")) {
return "metadata/internal host not allowed";
}
if (isIpLiteral(host) && !isPublicIp(host)) {
return "private/link-local IP not allowed";
}
return null;
}
/** # 成员变量: DNS 解析函数类型(返回该主机名解析到的全部 IP) **/
export type HostResolver = (host: string) => Promise<string[]>;
/** # 函数
* 完整 SSRF 校验:静态检查 + (可选)DNS 解析结果检查
* 参数: rawUrl - endpoint;resolve - DNS 解析器(本地 Bun/Node 运行时注入,Workers 环境可省略)
* 返回值: null 表示通过;否则返回拒绝原因
*/
export async function assertWebhookUrlSafe(
rawUrl: string,
resolve?: HostResolver,
allowPrivateHosts = false,
): Promise<string | null> {
const staticError = checkWebhookUrl(rawUrl, allowPrivateHosts);
if (staticError) return staticError;
if (allowPrivateHosts) return null;
if (resolve) {
const host = new URL(rawUrl).hostname;
if (!isIpLiteral(host)) {
let ips: string[];
try {
ips = await resolve(host);
} catch {
return "dns lookup failed";
}
if (ips.length === 0) return "dns lookup failed";
for (const ip of ips) {
if (!isPublicIp(ip)) return `resolves to private address (${ip})`;
}
}
}
return null;
}
+239
View File
@@ -0,0 +1,239 @@
/** API v1 / MCP 测试:Key 隔离、过期邮件 410、MCP 协议与工具 */
import { describe, expect, test } from "bun:test";
import { ingestEmail } from "../console/src/services/email_ingest.ts";
import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL } from "./helpers.ts";
/** # 函数
* 创建 Key 并返回明文
*/
async function createKey(t: Awaited<ReturnType<typeof makeTestEnv>>, cookie: string, kind: "api" | "mcp") {
const resp = await authedFetch(t, cookie, "http://localhost/api/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind }),
});
return ((await resp.json()) as { key: string }).key;
}
describe("Console API v1(Bearer wmh_ Key)", () => {
test("api key 可读资源;mcp key 访问 v1 被 403(kind 隔离)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const apiKey = await createKey(t, cookie, "api");
const mcpKey = await createKey(t, cookie, "mcp");
const ok = await t.app.fetch(
new Request("http://localhost/api/v1/mailboxes", {
headers: { Authorization: `Bearer ${apiKey}` },
}),
t.env,
);
expect(ok.status).toBe(200);
const data = (await ok.json()) as { items: { address: string }[] };
expect(data.items.map((m) => m.address)).toContain("*@example.test");
const bad = await t.app.fetch(
new Request("http://localhost/api/v1/mailboxes", {
headers: { Authorization: `Bearer ${mcpKey}` },
}),
t.env,
);
expect(bad.status).toBe(403);
});
test("无 key / 伪造 key / 已撤销 key 均被拒", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const noKey = await t.app.fetch(new Request("http://localhost/api/v1/mailboxes"), t.env);
expect(noKey.status).toBe(401);
const fake = await t.app.fetch(
new Request("http://localhost/api/v1/mailboxes", {
headers: { Authorization: `Bearer wmh_${"A".repeat(28)}` },
}),
t.env,
);
expect(fake.status).toBe(401);
const apiKey = await createKey(t, cookie, "api");
// 找到 key id 并撤销
const list = (await (
await authedFetch(t, cookie, "http://localhost/api/keys")
).json()) as { items: { id: string; revoked_at: string | null }[] };
const keyId = list.items.find((k) => !k.revoked_at)!.id;
await authedFetch(t, cookie, `http://localhost/api/keys/${keyId}/revoke`, { method: "POST" });
const revoked = await t.app.fetch(
new Request("http://localhost/api/v1/mailboxes", {
headers: { Authorization: `Bearer ${apiKey}` },
}),
t.env,
);
expect(revoked.status).toBe(401);
});
test("v1 可创建与删除 mailbox", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const apiKey = await createKey(t, cookie, "api");
const create = await t.app.fetch(
new Request("http://localhost/api/v1/mailboxes", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ domain_id: t.domainId, address: "newbox" }),
}),
t.env,
);
expect(create.status).toBe(201);
const { id, address } = (await create.json()) as { id: string; address: string };
expect(address).toBe("newbox@example.test");
const del = await t.app.fetch(
new Request(`http://localhost/api/v1/mailboxes/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${apiKey}` },
}),
t.env,
);
expect(del.status).toBe(200);
});
test("过期邮件:v1 详情返回 410,列表不出现", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const apiKey = await createKey(t, cookie, "api");
const email = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
await t.env.DB.prepare("UPDATE emails SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?")
.bind(email.id)
.run();
const detail = await t.app.fetch(
new Request(`http://localhost/api/v1/emails/${email.id}`, {
headers: { Authorization: `Bearer ${apiKey}` },
}),
t.env,
);
expect(detail.status).toBe(410);
const list = await t.app.fetch(
new Request("http://localhost/api/v1/emails", {
headers: { Authorization: `Bearer ${apiKey}` },
}),
t.env,
);
const data = (await list.json()) as { items: { id: string }[] };
expect(data.items.find((e) => e.id === email.id)).toBeUndefined();
});
});
describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
async function mcpCall(t: Awaited<ReturnType<typeof makeTestEnv>>, mcpKey: string, body: unknown) {
return t.app.fetch(
new Request("http://localhost/api/v1/mcp", {
method: "POST",
headers: { Authorization: `Bearer ${mcpKey}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
t.env,
);
}
test("api key 不能访问 MCP(权限基于 credential kind)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const apiKey = await createKey(t, cookie, "api");
const resp = await mcpCall(t, apiKey, { jsonrpc: "2.0", id: 1, method: "tools/list" });
expect(resp.status).toBe(403);
});
test("initialize + tools/list 返回六个工具与 serverInfo", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const mcpKey = await createKey(t, cookie, "mcp");
const init = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
const initData = (await init.json()) as { result: { serverInfo: { name: string }; protocolVersion: string } };
expect(initData.result.serverInfo.name).toBe("wmailhub");
expect(initData.result.protocolVersion).toBe("2025-06-18");
const tools = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 2, method: "tools/list" });
const toolsData = (await tools.json()) as { result: { tools: { name: string }[] } };
expect(toolsData.result.tools.map((x) => x.name)).toEqual([
"list_mailboxes",
"list_emails",
"get_email",
"create_mailbox",
"delete_mailbox",
"list_webhooks",
]);
});
test("tools/call:create_mailbox -> list_mailboxes -> get_email -> delete_mailbox", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const mcpKey = await createKey(t, cookie, "mcp");
const email = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
const created = await mcpCall(t, mcpKey, {
jsonrpc: "2.0",
id: 3,
method: "tools/call",
params: { name: "create_mailbox", arguments: { domain_id: t.domainId, address: "mcptest" } },
});
const createdData = (await created.json()) as { result: { content: { text: string }[] } };
expect(JSON.parse(createdData.result.content[0].text).address).toBe("mcptest@example.test");
const list = await mcpCall(t, mcpKey, {
jsonrpc: "2.0",
id: 4,
method: "tools/call",
params: { name: "list_mailboxes", arguments: {} },
});
const listData = (await list.json()) as { result: { content: { text: string }[] } };
const mailboxes = JSON.parse(listData.result.content[0].text) as {
mailboxes: { id: string; address: string }[];
};
expect(mailboxes.mailboxes.map((m) => m.address)).toContain("mcptest@example.test");
const got = await mcpCall(t, mcpKey, {
jsonrpc: "2.0",
id: 5,
method: "tools/call",
params: { name: "get_email", arguments: { id: email.id } },
});
const gotData = (await got.json()) as { result: { content: { text: string }[] } };
const gotEmail = JSON.parse(gotData.result.content[0].text) as { email: { subject: string } };
expect(gotEmail.email.subject).toBe("Test Mail");
const target = mailboxes.mailboxes.find((m) => m.address === "mcptest@example.test")!.id;
const del = await mcpCall(t, mcpKey, {
jsonrpc: "2.0",
id: 6,
method: "tools/call",
params: { name: "delete_mailbox", arguments: { id: target } },
});
const delData = (await del.json()) as { result: { content: { text: string }[] } };
expect(JSON.parse(delData.result.content[0].text).ok).toBe(true);
});
test("未知方法返回 -32601;GET 返回 405", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const mcpKey = await createKey(t, cookie, "mcp");
const bad = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 9, method: "no/such/method" });
const badData = (await bad.json()) as { error: { code: number } };
expect(badData.error.code).toBe(-32601);
const get = await t.app.fetch(
new Request("http://localhost/api/v1/mcp", {
headers: { Authorization: `Bearer ${mcpKey}` },
}),
t.env,
);
expect(get.status).toBe(405);
});
});
+58
View File
@@ -0,0 +1,58 @@
/** 认证与会话测试:登录、错误密码、会话校验、登出、CSRF Origin */
import { describe, expect, test } from "bun:test";
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
describe("用户认证(默认 admin/admin)", () => {
test("正确账密登录并拿到会话", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const me = await authedFetch(t, cookie, "http://localhost/api/auth/me");
expect(me.status).toBe(200);
const data = (await me.json()) as { user: { username: string } };
expect(data.user.username).toBe("admin");
});
test("错误密码返回 401", async () => {
const t = await makeTestEnv();
const resp = await t.app.fetch(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "wrong" }),
}),
t.env,
);
expect(resp.status).toBe(401);
});
test("无会话访问受保护接口返回 401", async () => {
const t = await makeTestEnv();
const resp = await t.app.fetch(new Request("http://localhost/api/nodes"), t.env);
expect(resp.status).toBe(401);
});
test("登出后会话失效(clients 清 cookie 后 401)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
await authedFetch(t, cookie, "http://localhost/api/auth/logout", { method: "POST" });
const me = await t.app.fetch(new Request("http://localhost/api/auth/me"), t.env);
expect(me.status).toBe(401);
});
test("伪造会话 cookie 被拒(签名不匹配)", async () => {
const t = await makeTestEnv();
const resp = await authedFetch(t, "wmh_session=eyJhIjoxfQ.deadbeef", "http://localhost/api/auth/me");
expect(resp.status).toBe(401);
});
test("跨域 Origin 写请求被拒(CSRF)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const resp = await authedFetch(t, cookie, "http://localhost/api/keys", {
method: "POST",
headers: { "Content-Type": "application/json", Origin: "https://evil.example.com" },
body: JSON.stringify({ kind: "api" }),
});
expect(resp.status).toBe(403);
});
});
+24
View File
@@ -0,0 +1,24 @@
From: Alice Sender <alice@example.org>
To: hello@example.test
Cc: bob@example.net
Subject: WmailHub 端到端测试邮件
Message-ID: <test-001@example.org>
Date: Wed, 30 Sep 2026 03:20:00 +0000
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="BOUNDARY-XYZ"
--BOUNDARY-XYZ
Content-Type: text/plain; charset=utf-8
Hello WmailHub! This is a plain text body.
--BOUNDARY-XYZ
Content-Type: text/html; charset=utf-8
<html><body><h1>Hello WmailHub</h1><p>This is <b>HTML</b> body.</p></body></html>
--BOUNDARY-XYZ
Content-Type: text/plain; charset=utf-8; name="notes.txt"
Content-Disposition: attachment; filename="notes.txt"
Content-Transfer-Encoding: base64
VGhpcyBpcyBhbiBhdHRhY2htZW50IGZpbGUu
--BOUNDARY-XYZ--
+41
View File
@@ -0,0 +1,41 @@
/**
* 本地 Webhook 接收器(联调用):
* bun tests/fixtures/webhook-receiver.ts [port]
* 验证 X-WmailHub-Signature = HMAC-SHA256(secret, `${timestamp}.${body}`),打印收到的 payload。
*/
const port = Number(process.argv[2] ?? 18790);
/** # 成员变量: 与创建 webhook 时一致的 secret(由环境变量传入) **/
const SECRET = process.env.WEBHOOK_SECRET ?? "";
Bun.serve({
port,
async fetch(req) {
if (req.method !== "POST") return new Response("ok");
const body = await req.text();
const ts = req.headers.get("X-WmailHub-Timestamp") ?? "";
const sig = req.headers.get("X-WmailHub-Signature") ?? "";
const delivery = req.headers.get("X-WmailHub-Delivery") ?? "";
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const expected = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${ts}.${body}`));
const expectedHex = [...new Uint8Array(expected)].map((b) => b.toString(16).padStart(2, "0")).join("");
const valid = expectedHex === sig;
console.log(
JSON.stringify({
t: new Date().toISOString(),
msg: "webhook received",
delivery,
signature_valid: valid,
payload: JSON.parse(body),
}),
);
return Response.json({ received: true });
},
});
console.log(`webhook receiver on :${port}`);
+172
View File
@@ -0,0 +1,172 @@
/**
* 测试基建:内存 D1 + 临时目录 R2 + Console app + 种子数据。
* 每个 test 文件独立建 env,互不干扰。
*/
import { Database } from "bun:sqlite";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createApp } from "../console/src/app.ts";
import { LocalD1 } from "../console/src/local/d1.ts";
import { LocalR2 } from "../console/src/local/r2.ts";
import type { Env } from "../console/src/env.ts";
import { encryptSecret } from "../console/src/db.ts";
import { hashPassword, sha256Hex, signNodeRequest } from "../shared/src/index.ts";
import { ids } from "../shared/src/index.ts";
/** # 成员变量: 迁移 SQL(一次读取,多处使用) **/
export const MIGRATION_SQL = await Bun.file(new URL("../migrations/0001_init.sql", import.meta.url)).text();
/** # 成员变量: 测试邮件样例(plain + html + 1 附件) **/
export const TEST_MAIL = `From: Alice <alice@example.org>\r
To: hello@example.test\r
Cc: bob@example.net\r
Subject: Test Mail\r
Message-ID: <t-1@example.org>\r
MIME-Version: 1.0\r
Content-Type: multipart/mixed; boundary="BB"\r
\r
--BB\r
Content-Type: text/plain\r
\r
plain body\r
--BB\r
Content-Type: text/html\r
\r
<b>html body</b>\r
--BB\r
Content-Type: text/plain; name="a.txt"\r
Content-Disposition: attachment; filename="a.txt"\r
\r
attachment-content\r
--BB--\r
`;
/** # 成员变量: 测试环境(内存库 + 临时 R2 目录) **/
export interface TestEnv {
env: Env;
app: ReturnType<typeof createApp>;
db: Database;
/** 默认节点 */
nodeId: string;
/** 默认节点 credential secret(明文,用于签名) */
nodeSecret: string;
/** catch-all 域名 example.test 对应的 mailbox id */
mailboxId: string;
domainId: string;
}
/** # 函数
* 构造隔离测试环境并播种:admin 用户、node、credential、domain、catch-all mailbox
*/
export async function makeTestEnv(): Promise<TestEnv> {
const db = new Database(":memory:", { strict: true });
db.exec(MIGRATION_SQL);
const env: Env = {
DB: new LocalD1(db),
R2: new LocalR2(mkdtempSync(path.join(tmpdir(), "wmh-r2-"))),
WMAILHUB_SIM_CF: "1",
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: "1",
};
const app = createApp();
const now = new Date().toISOString();
await env.DB.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (?, 'admin', ?, ?)")
.bind(ids.user(), await hashPassword("admin"), now)
.run();
const nodeId = "node_test1";
const nodeSecret = "wns_test_secret_for_signing";
await env.DB.prepare(
`INSERT INTO nodes (id, name, cloudflare_account_id, credential_id, status, ingress_url, ingress_secret_enc, provision_steps, created_at, updated_at)
VALUES (?, 'test-node', 'acct-1', 'cred1', 'online', 'http://127.0.0.1:18788/ingress/email', 'enc', '{}', ?, ?)`,
)
.bind(nodeId, now, now)
.run();
await env.DB.prepare(
"INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES ('cred1', ?, ?, ?, 'active', ?)",
)
.bind(nodeId, await sha256Hex(nodeSecret), await encryptSecret(env, nodeSecret), now)
.run();
const domainId = "dom_test1";
await env.DB.prepare(
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, created_at, updated_at) VALUES (?, 'acct-1', ?, 'example.test', 'active', ?, ?)",
)
.bind(domainId, nodeId, now, now)
.run();
const mailboxId = "mbx_test1";
await env.DB.prepare(
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES (?, ?, '*@example.test', 'active', 1, ?, ?)",
)
.bind(mailboxId, domainId, now, now)
.run();
return { env, app, db, nodeId, nodeSecret, mailboxId, domainId };
}
/** # 函数
* 构造登录会话 cookie(走真实登录接口)
*/
export async function loginCookie(test: TestEnv): Promise<string> {
const resp = await test.app.fetch(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "admin" }),
}),
test.env,
);
const setCookie = resp.headers.get("Set-Cookie") ?? "";
const m = /wmh_session=([^;]+)/.exec(setCookie);
if (!m) throw new Error("login failed: no session cookie");
return `wmh_session=${m[1]}`;
}
/** # 函数
* 构造已签名的 Node 邮件转发请求
*/
export async function signedEmailRequest(
test: TestEnv,
body: Uint8Array,
overrides: {
secret?: string;
nodeId?: string;
tamperSignature?: boolean;
tamperBody?: Uint8Array;
timestamp?: string;
reuseNonce?: string;
} = {},
): Promise<Request> {
const url = `http://localhost/api/internal/nodes/${overrides.nodeId ?? test.nodeId}/emails`;
// 签名始终基于原始 body;发送 tamperBody 即构成"签名-内容不一致"的篡改请求
const headers = await signNodeRequest(
overrides.secret ?? test.nodeSecret,
overrides.nodeId ?? test.nodeId,
"POST",
url,
body,
);
headers["Content-Type"] = "message/rfc822";
headers["X-WmailHub-Mail-From"] = "alice@example.org";
headers["X-WmailHub-Rcpt-To"] = "hello@example.test";
if (overrides.tamperSignature) headers["X-WmailHub-Signature"] = "0".repeat(64);
if (overrides.timestamp) headers["X-WmailHub-Timestamp"] = overrides.timestamp;
if (overrides.reuseNonce) headers["X-WmailHub-Nonce"] = overrides.reuseNonce;
const payload = overrides.tamperBody ?? body;
const ab = payload.buffer.slice(
payload.byteOffset,
payload.byteOffset + payload.byteLength,
) as ArrayBuffer;
return new Request(url, { method: "POST", headers, body: ab });
}
/** # 函数
* 会话 GET/POST 便捷封装
*/
export function authedFetch(test: TestEnv, cookie: string, url: string, init?: RequestInit) {
const headers = new Headers(init?.headers);
headers.set("Cookie", cookie);
return test.app.fetch(new Request(url, { ...init, headers }), test.env);
}
+154
View File
@@ -0,0 +1,154 @@
/** Cloudflare OAuth(本地 sim 模式)测试:state CSRF、自动安装幂等、连接存储 */
import { describe, expect, test } from "bun:test";
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
describe("Cloudflare OAuth(sim 模式)", () => {
test("start 生成 state;sim approve 完成自动安装(OAuth→identity→discover→worker→routing→domains→mailboxes)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// Add Node
const created = (await (
await authedFetch(t, cookie, "http://localhost/api/nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Sim Node", ingress_url: "http://127.0.0.1:18788/ingress/email" }),
})
).json()) as { node_id: string; enrollment_token: string };
expect(created.enrollment_token.startsWith("wenr_")).toBe(true);
// OAuth start
const start = (await (
await authedFetch(t, cookie, `http://localhost/api/oauth/cloudflare/start?node_id=${created.node_id}`)
).json()) as { url: string; state: string };
expect(start.url).toContain("/oauth/sim/authorize?state=");
// sim approve(等价于用户在 Cloudflare 授权页点击同意)
const approve = (await (
await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: start.state }),
})
).json()) as { ok: boolean; error?: string };
expect(approve.ok).toBe(true);
// 安装结果
const detail = (await (
await authedFetch(t, cookie, `http://localhost/api/nodes/${created.node_id}`)
).json()) as {
node: { status: string; cloudflare_account_id: string | null };
steps: Record<string, { status: string }>;
domains: { name: string }[];
};
expect(detail.node.status).toBe("offline"); // 等待 Node 心跳上线
expect(detail.node.cloudflare_account_id).toBe("sim-account-0001");
for (const key of ["oauth", "identity", "discover", "email_worker", "email_routing", "domains", "mailboxes"]) {
expect(detail.steps[key].status).toBe("done");
}
expect(detail.domains.map((d) => d.name)).toContain("example.test");
// catch-all mailbox 已创建
const mb = await t.env.DB.prepare("SELECT address FROM mailboxes WHERE is_catch_all = 1").all<{ address: string }>();
expect(mb.results.map((r) => r.address)).toContain("*@example.test");
// sim 资源记录(Email Worker / Routing rule)
const sim = await t.env.DB.prepare("SELECT kind FROM cf_sim_resources").all<{ kind: string }>();
const kinds = sim.results.map((r) => r.kind);
expect(kinds).toContain("email_worker");
expect(kinds).toContain("routing_rule");
// OAuth token 已加密入库(不含明文 token)
const conn = await t.env.DB.prepare("SELECT encrypted_access_token FROM oauth_connections").first<{
encrypted_access_token: string;
}>();
expect(conn?.encrypted_access_token).toBeTruthy();
expect(conn!.encrypted_access_token.startsWith("sim_")).toBe(false);
});
test("state 一次性:重放回调 state 被拒(OAuth CSRF 防护)", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const created = (await (
await authedFetch(t, cookie, "http://localhost/api/nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "n" }),
})
).json()) as { node_id: string };
const { state } = (await (
await authedFetch(t, cookie, `http://localhost/api/oauth/cloudflare/start?node_id=${created.node_id}`)
).json()) as { state: string };
const first = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state }),
});
expect(first.status).toBe(200);
// state 已消费,重放被拒
const second = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state }),
});
expect(second.status).toBe(400);
});
test("伪造/过期 state 被拒;真实 callback 端点同样拒绝", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const bad = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: "cfstate_forged" }),
});
expect(bad.status).toBe(400);
const cb = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/callback?code=x&state=forged");
expect(cb.status).toBe(400);
});
test("enrollment:token 换取身份,凭证明文仅返回一次且库中只存摘要/密文", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const created = (await (
await authedFetch(t, cookie, "http://localhost/api/nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "n2" }),
})
).json()) as { node_id: string; enrollment_token: string };
const enroll = (await (
await t.app.fetch(
new Request("http://localhost/api/internal/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enrollment_token: created.enrollment_token }),
}),
t.env,
)
).json()) as { node_id: string; credential_secret: string; ingress_secret: string };
expect(enroll.node_id).toBe(created.node_id);
expect(enroll.credential_secret.startsWith("wns_")).toBe(true);
// 库中不存明文 secret
const cred = await t.env.DB.prepare(
"SELECT secret_hash, secret_enc FROM node_credentials WHERE node_id = ? AND status='active'",
)
.bind(created.node_id)
.first<{ secret_hash: string; secret_enc: string }>();
expect(cred?.secret_hash).not.toBe(enroll.credential_secret);
expect(cred?.secret_enc).not.toContain(enroll.credential_secret);
// 无效 token 被拒
const badEnroll = await t.app.fetch(
new Request("http://localhost/api/internal/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enrollment_token: "wenr_invalid" }),
}),
t.env,
);
expect(badEnroll.status).toBe(401);
});
});
+190
View File
@@ -0,0 +1,190 @@
/** 邮件管线测试:plain/html/附件/多收件人/malformed/超大邮件/TTL/凭证撤销 */
import { describe, expect, test } from "bun:test";
import { ingestEmail, resolveMailbox } from "../console/src/services/email_ingest.ts";
import { runCron } from "../console/src/services/cleanup.ts";
import { processDueDeliveries } from "../console/src/services/webhook_delivery.ts";
import { MAX_EMAIL_BYTES } from "../shared/src/index.ts";
import { makeTestEnv, signedEmailRequest, TEST_MAIL } from "./helpers.ts";
describe("邮件接收管线", () => {
test("完整解析:subject/from/to/cc/text/html/附件/邮箱关联/expires_at", async () => {
const t = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const resp = await t.app.fetch(await signedEmailRequest(t, body), t.env);
expect(resp.status).toBe(202);
const data = (await resp.json()) as { id: string; mailbox_id: string; expires_at: string };
expect(data.mailbox_id).toBe(t.mailboxId); // catch-all 匹配
const email = (await t.env.DB.prepare("SELECT * FROM emails WHERE id = ?").bind(data.id).first()) as Record<
string,
unknown
>;
expect(email.subject).toBe("Test Mail");
expect(email.from_address).toBe("alice@example.org");
expect(JSON.parse(email.to_addresses as string)).toEqual(["hello@example.test"]);
expect(JSON.parse(email.cc_addresses as string)).toEqual(["bob@example.net"]);
expect(email.text_body).toContain("plain body");
expect(email.html_body).toContain("html body");
// expires_at ≈ received_at + 86400s
const delta = (new Date(email.expires_at as string).getTime() - new Date(email.received_at as string).getTime()) / 1000;
expect(delta).toBe(86_400);
const atts = await t.env.DB.prepare("SELECT * FROM email_attachments WHERE email_id = ?").bind(data.id).all();
expect(atts.results.length).toBe(1);
expect(atts.results[0].filename).toBe("a.txt");
// raw 已入 R2
const obj = await t.env.R2.get(email.raw_storage_key as string);
expect(obj).not.toBeNull();
const attObj = await t.env.R2.get(atts.results[0].storage_key as string);
expect(await attObj?.text()).toContain("attachment-content");
});
test("malformed 邮件不拒收:降级入库,raw 可取回", async () => {
const t = await makeTestEnv();
const garbage = new TextEncoder().encode("\x00\x01not-a-mime-at-all\r\n###");
const resp = await t.app.fetch(await signedEmailRequest(t, garbage), t.env);
expect(resp.status).toBe(202);
const count = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM emails").first<{ n: number }>();
expect(count?.n).toBe(1);
const email = await t.env.DB.prepare("SELECT * FROM emails").first<Record<string, unknown>>();
expect(email?.subject).toBe("(unparsable mail)");
const obj = await t.env.R2.get(email?.raw_storage_key as string);
expect(obj).not.toBeNull();
});
test("超大附件被跳过,邮件本身照常入库(限额防护)", async () => {
const t = await makeTestEnv();
const { MAX_ATTACHMENT_BYTES } = await import("../shared/src/index.ts");
const bigB64 = Buffer.from(new Uint8Array(MAX_ATTACHMENT_BYTES + 1)).toString("base64");
const mail = [
"From: a@b.c",
"To: hello@example.test",
"Subject: big att",
"MIME-Version: 1.0",
"Content-Type: multipart/mixed; boundary=\"BB\"",
"",
"--BB",
"Content-Type: application/octet-stream; name=\"big.bin\"",
"Content-Disposition: attachment; filename=\"big.bin\"",
"Content-Transfer-Encoding: base64",
"",
bigB64,
"--BB",
"Content-Type: text/plain",
"",
"body",
"--BB--",
].join("\r\n");
const result = await ingestEmail(t.env, {
raw: new TextEncoder().encode(mail),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
const atts = await t.env.DB.prepare("SELECT id FROM email_attachments WHERE email_id = ?")
.bind(result.id)
.all();
expect(atts.results.length).toBe(0); // 超限附件跳过
const row = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(result.id).first();
expect(row).not.toBeNull(); // 邮件入库
});
test("超大邮件返回 413(资源耗尽防护)", async () => {
const t = await makeTestEnv();
const big = new Uint8Array(MAX_EMAIL_BYTES + 1);
const resp = await t.app.fetch(await signedEmailRequest(t, big), t.env);
expect(resp.status).toBe(413);
});
test("精确地址匹配优先于 catch-all", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
await t.env.DB.prepare(
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_exact', 'dom_test1', 'hello@example.test', 'active', 0, ?, ?)",
)
.bind(now, now)
.run();
const hit = await resolveMailbox(t.env, ["hello@example.test"]);
expect(hit).toBe("mbx_exact");
const other = await resolveMailbox(t.env, ["nobody@example.test"]);
expect(other).toBe(t.mailboxId); // catch-all
const miss = await resolveMailbox(t.env, ["x@other.example"]);
expect(miss).toBeNull();
});
test("mailbox 自定义 retention 覆盖默认 1 天", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
await t.env.DB.prepare(
"UPDATE mailboxes SET retention_seconds = 300 WHERE id = ?",
)
.bind(t.mailboxId)
.run();
const result = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
const delta =
(new Date(result.expiresAt).getTime() - new Date(now).getTime()) / 1000;
expect(delta).toBeLessThan(400);
});
});
describe("TTL 清理与 orphan", () => {
test("过期邮件被清理(R2 + DB),未过期保留", async () => {
const t = await makeTestEnv();
const result = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
// 人为把 expires_at 改到过去
await t.env.DB.prepare("UPDATE emails SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?")
.bind(result.id)
.run();
const stats = await runCron(t.env, () => processDueDeliveries(t.env));
expect(stats.expiredEmails).toBe(1);
const row = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(result.id).first();
expect(row).toBeNull();
const atts = await t.env.DB.prepare("SELECT id FROM email_attachments WHERE email_id = ?")
.bind(result.id)
.all();
expect(atts.results.length).toBe(0);
const obj = await t.env.R2.get(result.id ? `emails/2000/01/01/${result.id}/raw.eml` : "nonexist");
expect(obj).toBeNull();
});
test("orphan 附件与投递记录被清理", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
await t.env.DB.prepare(
"INSERT INTO email_attachments (id, email_id, filename, size) VALUES ('att_orphan', 'msg_gone', 'x.txt', 1)",
)
.run();
await t.env.DB.prepare(
"INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, created_at) VALUES ('dlv_orphan', 'whk_gone', 'msg_gone', 'pending', 0, ?)",
)
.bind(now)
.run();
const stats = await runCron(t.env, () => processDueDeliveries(t.env));
expect(stats.orphanAttachments).toBe(1);
expect(stats.orphanDeliveries).toBe(1);
});
});
describe("credential 撤销", () => {
test("撤销后节点请求被 401,且不因重试恢复", async () => {
const t = await makeTestEnv();
await t.env.DB.prepare("UPDATE node_credentials SET status='revoked', revoked_at=? WHERE id='cred1'")
.bind(new Date().toISOString())
.run();
const body = new TextEncoder().encode(TEST_MAIL);
const resp = await t.app.fetch(await signedEmailRequest(t, body), t.env);
expect(resp.status).toBe(401);
const data = (await resp.json()) as { error: string };
expect(data.error).toContain("revoked");
});
});
+141
View File
@@ -0,0 +1,141 @@
/** 协议与密钥测试:HMAC 签名、防重放、防篡改、时间窗、wmh_ Key 格式 */
import { describe, expect, test } from "bun:test";
import {
canonicalString,
computeSignature,
generateWmailKey,
isValidWmailKey,
verifyNodeRequest,
} from "../shared/src/index.ts";
import { makeTestEnv, signedEmailRequest, TEST_MAIL } from "./helpers.ts";
describe("wmh_ Key 规范", () => {
test("生成的 Key 以 wmh_ 开头且总长 32", () => {
const key = generateWmailKey();
expect(key.startsWith("wmh_")).toBe(true);
expect(key.length).toBe(32);
expect(isValidWmailKey(key)).toBe(true);
});
test("Key 随机且格式校验拒绝非法输入", () => {
const a = generateWmailKey();
const b = generateWmailKey();
expect(a).not.toBe(b);
expect(isValidWmailKey("wmh_short")).toBe(false);
expect(isValidWmailKey("other_" + "x".repeat(28))).toBe(false);
expect(isValidWmailKey("wmh_" + "x".repeat(27))).toBe(false); // 31
expect(isValidWmailKey("wmh_" + "x".repeat(29))).toBe(false); // 33
expect(isValidWmailKey("wmh_" + "!".repeat(28))).toBe(false); // 非法字符
});
});
describe("HMAC 请求签名", () => {
test("canonical 串格式符合 docs/04", () => {
const c = canonicalString({
method: "POST",
path: "/api/x",
timestamp: "123",
nonce: "n",
bodyHash: "h",
});
expect(c).toBe("POST\n/api/x\n123\nn\nh");
});
test("正确签名可通过校验", async () => {
const body = new TextEncoder().encode("hello");
const headers = new Headers({
"Authorization": "WmailHub-Node node_1",
"X-WmailHub-Timestamp": String(Math.floor(Date.now() / 1000)),
"X-WmailHub-Nonce": "nonce-1",
"X-WmailHub-Body-SHA256": await (async () => {
const { sha256Hex } = await import("../shared/src/index.ts");
return sha256Hex(body);
})(),
"X-WmailHub-Signature": await computeSignature("s", {
method: "POST",
path: "/p",
timestamp: String(Math.floor(Date.now() / 1000)),
nonce: "nonce-1",
bodyHash: await (async () => {
const { sha256Hex } = await import("../shared/src/index.ts");
return sha256Hex(body);
})(),
}),
"X-WmailHub-Protocol": "1",
});
const result = await verifyNodeRequest({
secret: "s",
method: "POST",
url: "http://x/p",
body,
headers,
});
expect(result.ok).toBe(true);
});
test("错误 secret 签名被拒(伪造 Node 请求)", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const req = await signedEmailRequest(test, body, { secret: "wns_wrong_secret" });
const resp = await test.app.fetch(req, test.env);
expect(resp.status).toBe(401);
});
test("body 篡改被拒", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const tampered = new TextEncoder().encode(TEST_MAIL + "injected");
const req = await signedEmailRequest(test, body, { tamperBody: tampered });
const resp = await test.app.fetch(req, test.env);
expect(resp.status).toBe(401);
});
test("过期 timestamp 超窗被拒", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const old = String(Math.floor(Date.now() / 1000) - 3600); // 1 小时前
const req = await signedEmailRequest(test, body, { timestamp: old });
const resp = await test.app.fetch(req, test.env);
expect(resp.status).toBe(401);
const data = (await resp.json()) as { error: string };
expect(data.error).toContain("timestamp");
});
test("nonce 重放被拒(replay attack)", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const req1 = await signedEmailRequest(test, body);
const resp1 = await test.app.fetch(req1, test.env);
expect(resp1.status).toBe(202);
// 取第一次请求的 nonce 重放(重新签名同一 nonce)
const url = `http://localhost/api/internal/nodes/${test.nodeId}/emails`;
const { signNodeRequest } = await import("../shared/src/index.ts");
const headers = await signNodeRequest(test.nodeSecret, test.nodeId, "POST", url, body);
headers["Content-Type"] = "message/rfc822";
const resp2a = await test.app.fetch(new Request(url, { method: "POST", headers, body }), test.env);
expect(resp2a.status).toBe(202);
// 抓第二次请求的完整头,原样重发构成 replay(同 nonce 同签名)
const headers3 = await signNodeRequest(test.nodeSecret, test.nodeId, "POST", url, body);
headers3["Content-Type"] = "message/rfc822";
const replayReq = () => new Request(url, { method: "POST", headers: headers3, body });
const respFirst = await test.app.fetch(replayReq(), test.env);
expect(respFirst.status).toBe(202);
const resp3 = await test.app.fetch(replayReq(), test.env);
expect(resp3.status).toBe(401);
const data = (await resp3.json()) as { error: string };
expect(data.error).toContain("replay");
});
test("错误协议版本被拒", async () => {
const test = await makeTestEnv();
const body = new TextEncoder().encode(TEST_MAIL);
const req = await signedEmailRequest(test, body);
const headers = new Headers(req.headers);
headers.set("X-WmailHub-Protocol", "9");
const resp = await test.app.fetch(
new Request(req.url, { method: "POST", headers, body }),
test.env,
);
expect(resp.status).toBe(400);
});
});
+93
View File
@@ -0,0 +1,93 @@
/**
* UI 冒烟测试(Playwright headless):
* 登录 admin/admin → 概览/节点/域名/邮箱/邮件/Webhooks/Keys 各页渲染 → 截图。
* bun tests/ui-smoke.ts
*/
import { chromium } from "playwright-core";
const BASE = process.env.WMAILHUI_BASE ?? "http://127.0.0.1:18787";
const EXEC = `${process.env.HOME}/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome`;
const SHOT_DIR = "/tmp/wmh-shots";
await Bun.$`mkdir -p ${SHOT_DIR}`.quiet();
const browser = await chromium.launch({
executablePath: EXEC,
headless: true,
args: ["--no-sandbox"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(`pageerror: ${err.message}`));
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(`console: ${msg.text()}`);
});
const results: { step: string; ok: boolean; note?: string }[] = [];
const check = (step: string, ok: boolean, note?: string) => {
results.push({ step, ok, note });
console.log(`${ok ? "✓" : "✗"} ${step}${note ? ` — ${note}` : ""}`);
};
// 1. 登录页
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await page.waitForSelector("input[placeholder='用户名']", { timeout: 8000 });
check("登录页渲染(含 LOGO/表单)", (await page.title()).includes("WmailHub"));
await page.screenshot({ path: `${SHOT_DIR}/01-login.png` });
// 2. 登录 admin/admin
await page.fill("input[placeholder='用户名']", "admin");
await page.fill("input[placeholder='密码']", "admin");
await page.click("button[type=submit]");
await page.waitForURL(`${BASE}/`, { timeout: 8000 });
await page.waitForSelector("text=概览", { timeout: 8000 });
check("登录成功进入概览页", true);
await page.waitForTimeout(600);
await page.screenshot({ path: `${SHOT_DIR}/02-dashboard.png` });
// 3. 各页面导航渲染
const pages = [
{ path: "/nodes", expect: "节点管理", shot: "03-nodes.png" },
{ path: "/domains", expect: "域名管理", shot: "04-domains.png" },
{ path: "/mailboxes", expect: "邮箱管理", shot: "05-mailboxes.png" },
{ path: "/emails", expect: "邮件", shot: "06-emails.png" },
{ path: "/webhooks", expect: "Webhooks", shot: "07-webhooks.png" },
{ path: "/keys", expect: "API / MCP Keys", shot: "08-keys.png" },
];
for (const p of pages) {
await page.goto(`${BASE}${p.path}`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(`text=${p.expect}`, { timeout: 8000 });
await page.waitForTimeout(500);
const hasTable = (await page.locator(".ant-table").count()) > 0;
check(`页面 ${p.path} 渲染${hasTable ? "(含表格)" : ""}`, true);
await page.screenshot({ path: `${SHOT_DIR}/${p.shot}` });
}
// 4. 邮件详情(点击第一行)
await page.goto(`${BASE}/emails`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table-row", { timeout: 8000 });
await page.locator(".ant-table-row").first().click();
await page.waitForSelector(".ant-drawer-open", { timeout: 8000 });
const drawerText = await page.locator(".ant-drawer-title").textContent();
check("邮件详情抽屉打开", Boolean(drawerText && drawerText.includes("端到端")));
await page.screenshot({ path: `${SHOT_DIR}/09-email-detail.png` });
// 5. 生成 API Key 流程
await page.goto(`${BASE}/keys`, { waitUntil: "domcontentloaded" });
await page.waitForSelector("text=生成 Key", { timeout: 8000 });
await page.click("button:has-text('生成 Key')");
await page.click(".ant-dropdown-menu-item:has-text('生成 API Key')");
await page.waitForSelector(".ant-modal:has-text('仅显示一次')", { timeout: 8000 });
const keyText = await page.locator(".ant-modal .mono").first().textContent();
check("生成 API Key 弹窗(wmh_ 32 位)", Boolean(keyText && keyText.startsWith("wmh_") && keyText.trim().length === 32), keyText?.trim());
await page.screenshot({ path: `${SHOT_DIR}/10-key-created.png` });
await browser.close();
const failed = results.filter((r) => !r.ok);
console.log(`\n==== UI 冒烟结果: ${results.length - failed.length}/${results.length} 通过 ====`);
if (errors.length > 0) {
console.log("浏览器错误:");
for (const e of errors.slice(0, 10)) console.log(" ", e);
}
process.exit(failed.length > 0 ? 1 : 0);
+172
View File
@@ -0,0 +1,172 @@
/** Webhook 测试:SSRF 校验、投递签名、重试与退避、失败状态、手动测试投递 */
import { describe, expect, test } from "bun:test";
import { ingestEmail } from "../console/src/services/email_ingest.ts";
import { attemptDelivery, processDueDeliveries } from "../console/src/services/webhook_delivery.ts";
import { hmacSha256Hex } from "../shared/src/index.ts";
import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL } from "./helpers.ts";
/** # 函数
* 起一个本地 HTTP 接收器,记录请求并可控返回状态
*/
function startReceiver(mode: "ok" | "fail500"): { url: string; requests: { headers: Headers; body: string }[]; stop: () => void } {
const requests: { headers: Headers; body: string }[] = [];
const server = Bun.serve({
port: 0,
fetch: async (req) => {
requests.push({ headers: req.headers, body: await req.text() });
if (mode === "fail500") return new Response("no", { status: 500 });
return Response.json({ ok: true });
},
});
return { url: `http://127.0.0.1:${server.port}/hook`, requests, stop: () => server.stop(true) };
}
describe("Webhook", () => {
test("创建 webhook 时拒绝私网/localhost endpoint(SSRF)", async () => {
const t = await makeTestEnv();
// SSRF 防护在"未开启本地联调开关"时生效(生产默认态)
delete (t.env as { WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string }).WMAILHUB_ALLOW_PRIVATE_WEBHOOKS;
const cookie = await loginCookie(t);
for (const endpoint of [
"http://127.0.0.1:8080/hook",
"http://localhost/hook",
"http://169.254.169.254/latest/meta-data",
"http://10.0.0.5/hook",
"http://[::1]/hook",
"ftp://example.com/x",
]) {
const resp = await authedFetch(t, cookie, "http://localhost/api/webhooks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "x", endpoint }),
});
expect(resp.status).toBe(400);
}
});
test("允许公网 endpoint 创建成功", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const resp = await authedFetch(t, cookie, "http://localhost/api/webhooks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "ok", endpoint: "https://hooks.example.com/wmail" }),
});
expect(resp.status).toBe(201);
const data = (await resp.json()) as { secret: string };
expect(data.secret.startsWith("whsec_")).toBe(true);
});
test("投递成功:签名头正确且 2xx 落 success", async () => {
const t = await makeTestEnv();
const recv = startReceiver("ok");
const now = new Date().toISOString();
await t.env.DB.prepare(
"INSERT INTO webhooks (id, name, endpoint, secret, enabled, created_at, updated_at) VALUES ('whk1', 'w', ?, 'whsec_test', 1, ?, ?)",
)
.bind(recv.url, now, now)
.run();
const email = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
const dlv = await t.env.DB.prepare("SELECT id FROM webhook_deliveries").first<{ id: string }>();
expect(dlv).not.toBeNull();
const stats = await processDueDeliveries(t.env, undefined, 20, true);
expect(stats.success).toBe(1);
const row = await t.env.DB.prepare("SELECT status, delivered_at FROM webhook_deliveries").first<{
status: string;
delivered_at: string | null;
}>();
expect(row?.status).toBe("success");
// 验证签名
const req = recv.requests[0];
const ts = req.headers.get("X-WmailHub-Timestamp") ?? "";
const sig = await hmacSha256Hex("whsec_test", String(ts).concat(".", req.body));
expect(req.headers.get("X-WmailHub-Signature") ?? null).toBe(sig);
expect(req.headers.get("X-WmailHub-Event") ?? null).toBe("email.received");
expect(req.headers.get("X-WmailHub-Delivery") ?? null).toBe(dlv?.id ?? null);
const payload = JSON.parse(req.body) as Record<string, unknown>;
expect(payload.event).toBe("email.received");
expect(payload.id).toBe(email.id);
expect(payload.mailbox).toBe("*@example.test");
recv.stop();
});
test("5xx 失败进入重试队列,退避时间递增,超过上限 exhausted", async () => {
const t = await makeTestEnv();
const now = new Date().toISOString();
await t.env.DB.prepare(
"INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, created_at) VALUES ('d1', 'whk_gone', 'msg_gone', 'pending', 0, ?)",
)
.bind(now)
.run();
// msg_gone 不存在(触发 email expired 分支前先看 join):join 不到直接跳过 — 改用真实邮件
await t.env.DB.prepare("DELETE FROM webhook_deliveries WHERE id='d1'").run();
// 建真实 webhook 行(endpoint 不可达),投递 join 需要
await t.env.DB.prepare(
"INSERT INTO webhooks (id, name, endpoint, secret, enabled, created_at, updated_at) VALUES ('whk_gone', 'gone', 'http://127.0.0.1:1/hook', 's', 1, ?, ?)",
)
.bind(now, now)
.run();
const email = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
await t.env.DB.prepare(
"INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, created_at) VALUES ('d2', 'whk_gone', ?, 'pending', 0, ?)",
)
.bind(email.id, now)
.run();
// 直连 attemptDelivery 到一个 5xx 地址(fetch 会失败)
const result = await attemptDelivery(
{ id: "whk_gone", endpoint: "http://127.0.0.1:1/hook", secret: "s" },
"d2",
{ event: "email.received" },
undefined,
true,
);
expect(result.ok).toBe(false);
// 状态机:连续失败到上限 → exhausted(每轮把 next_retry_at 拉回现在模拟时间流逝)
const { WEBHOOK_MAX_ATTEMPTS } = await import("../shared/src/index.ts");
let attempts = 0;
for (let i = 0; i < WEBHOOK_MAX_ATTEMPTS + 1; i++) {
const stats = await processDueDeliveries(t.env, undefined, 20, true);
if (stats.processed === 0) break;
attempts++;
await t.env.DB.prepare("UPDATE webhook_deliveries SET next_retry_at = ? WHERE id='d2'")
.bind(new Date().toISOString())
.run();
}
const row = await t.env.DB.prepare("SELECT status, attempt_count FROM webhook_deliveries WHERE id='d2'").first<{
status: string;
attempt_count: number;
}>();
// 目标不可达(端口 1),每次都失败,最终 exhausted
expect(row?.status).toBe("exhausted");
expect(row?.attempt_count).toBe(attempts);
});
test("手动 test 投递走同一条签名链路", async () => {
const t = await makeTestEnv();
const recv = startReceiver("ok");
const cookie = await loginCookie(t);
const create = await authedFetch(t, cookie, "http://localhost/api/webhooks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "t", endpoint: recv.url }),
});
const { id } = (await create.json()) as { id: string };
const testResp = await authedFetch(t, cookie, `http://localhost/api/webhooks/${id}/test`, { method: "POST" });
const data = (await testResp.json()) as { ok: boolean };
expect(data.ok).toBe(true);
expect(recv.requests.length).toBeGreaterThanOrEqual(1);
recv.stop();
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM", "DOM.Iterable", "WebWorker"],
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"types": ["bun-types"]
},
"include": ["shared/src/**/*", "console/src/**/*", "console/frontend/src/**/*", "node/src/**/*", "worker/src/**/*", "tests/**/*", "scripts/**/*"]
}
+7
View File
@@ -0,0 +1,7 @@
/** Email Worker 环境变量(wrangler vars 注入) */
export interface ForwarderEnv {
/** Node ingress 完整 URL */
NODE_INGRESS_URL: string;
/** Worker -> Node 共享密钥 */
NODE_INGRESS_SECRET: string;
}
+74
View File
@@ -0,0 +1,74 @@
/**
* WmailHub Email Worker(部署入口):
* Cloudflare Email Routing -> email() handler -> Node ingress。
* 只做转发,不存业务数据(docs/01);持有 Node ingress secret(docs/08)。
*
* 生产部署:Console provisioning 自动上传脚本;或 `bunx wrangler deploy`(worker/)。
* 本地联调:`bun worker/src/local.ts` 起 HTTP 服务,POST /__email_test 模拟邮件事件。
*/
import type { ForwarderEnv } from "./env.ts";
/** # 成员变量: Cloudflare Email Message 最小接口 **/
export interface EmailMessage {
from: string;
to: string;
raw: ReadableStream;
}
/** # 函数
* 转发 raw MIME 到 Node ingress
*/
export async function forward(env: ForwarderEnv, raw: ArrayBuffer, from: string, to: string): Promise<Response> {
const resp = await fetch(env.NODE_INGRESS_URL, {
method: "POST",
headers: {
"Content-Type": "message/rfc822",
"X-WmailHub-Ingress-Secret": env.NODE_INGRESS_SECRET,
"X-WmailHub-Mail-From": from,
"X-WmailHub-Rcpt-To": to,
},
body: raw,
});
return resp;
}
/** # 函数
* 处理 Email Worker 邮件事件(与测试入口共用)
*/
export async function handleEmailEvent(env: ForwarderEnv, message: EmailMessage): Promise<void> {
const raw = await new Response(message.raw).arrayBuffer();
const resp = await forward(env, raw, message.from, message.to);
if (!resp.ok) {
throw new Error(`node ingress responded ${resp.status}`);
}
}
/** # 成员变量: Workers 导出(email + fetch) **/
export default {
async email(message: EmailMessage, env: ForwarderEnv): Promise<void> {
await handleEmailEvent(env, message);
},
async fetch(request: Request, env: ForwarderEnv): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/__health") {
return Response.json({ ok: true, service: "wmailhub-email-worker" });
}
if (request.method === "POST" && url.pathname === "/__email_test") {
if (request.headers.get("X-WmailHub-Ingress-Secret") !== env.NODE_INGRESS_SECRET) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const from = request.headers.get("X-WmailHub-Test-From") || "sender@example.org";
const to = request.headers.get("X-WmailHub-Test-To") || "hello@example.com";
const raw = await request.arrayBuffer();
try {
const resp = await forward(env, raw, from, to);
return Response.json({ ok: resp.ok, status: resp.status }, { status: resp.ok ? 200 : 502 });
} catch (err) {
return Response.json({ error: String(err) }, { status: 502 });
}
}
return Response.json({ error: "not found" }, { status: 404 });
},
};
+46
View File
@@ -0,0 +1,46 @@
/**
* Email Worker 本地联调服务:把部署入口的 fetch handler 跑在 Bun 上。
* bun worker/src/local.ts
* 测试:POST http://127.0.0.1:18789/__email_test
* headers: X-WmailHub-Ingress-Secret / X-WmailHub-Test-From / X-WmailHub-Test-To
* body: raw MIME
*/
import worker from "./index.ts";
import type { ForwarderEnv } from "./env.ts";
const env: ForwarderEnv = {
NODE_INGRESS_URL: process.env.NODE_INGRESS_URL ?? "http://127.0.0.1:18788/ingress/email",
NODE_INGRESS_SECRET: process.env.NODE_INGRESS_SECRET ?? "",
};
if (!env.NODE_INGRESS_SECRET) {
console.error("missing NODE_INGRESS_SECRET env");
process.exit(1);
}
const PORT = Number(process.env.WMAILHUB_WORKER_PORT ?? 18789);
const server = Bun.serve({
port: PORT,
hostname: "0.0.0.0",
fetch: (req) => worker.fetch(req, env),
});
console.log(
JSON.stringify({
t: new Date().toISOString(),
lvl: "info",
msg: "email worker started",
port: PORT,
node_ingress: env.NODE_INGRESS_URL,
}),
);
process.on("SIGINT", () => {
server.stop(true);
process.exit(0);
});
process.on("SIGTERM", () => {
server.stop(true);
process.exit(0);
});
+15
View File
@@ -0,0 +1,15 @@
// WmailHub Email Worker —— Cloudflare 部署配置(生产)。
// 通常不由本文件部署:Console 在自动安装 Node 时通过 Cloudflare API 上传脚本并注入
// Node ingress 地址/密钥(docs/02)。此配置用于手工部署调试:
// bunx wrangler deploy (在 worker/ 目录)
// 部署后还需在 Zone 的 Email Routing -> Email Workers 中把 catch-all 指到该 Worker
// (Console 的 provisioning 会自动完成同样动作)。
{
"name": "wmailhub-email-worker",
"main": "src/index.ts",
"compatibility_date": "2026-09-01",
"vars": {
"NODE_INGRESS_URL": "http://127.0.0.1:8788/ingress/email",
"NODE_INGRESS_SECRET": "replace-with-node-ingress-secret"
}
}