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:
+25
@@ -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/
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 : "生成失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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 }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 端业务路由
|
||||||
@@ -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。
|
||||||
@@ -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 必须验证。
|
||||||
@@ -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 网络错误,但不承担长期邮件队列职责。
|
||||||
@@ -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 后不得无限重试。
|
||||||
|
|
||||||
|
协议必须版本化。
|
||||||
@@ -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 秒。
|
||||||
@@ -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 和临时日志都应有生命周期,不做无限期保存。
|
||||||
@@ -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。
|
||||||
@@ -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 天,过期自动删除。
|
||||||
@@ -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 限制调整。
|
||||||
@@ -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 自动删除。
|
||||||
|
|
||||||
|
如果正常流程要求用户手工登录服务器执行安装命令,则不符合设计目标。
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** Node 版本:与项目根 package.json 一致(全局版本规范,禁止手写常量) */
|
||||||
|
import pkg from "../../package.json";
|
||||||
|
|
||||||
|
export const APP_VERSION: string = pkg.version;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+24
@@ -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--
|
||||||
Vendored
+41
@@ -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}`);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** Email Worker 环境变量(wrangler vars 注入) */
|
||||||
|
export interface ForwarderEnv {
|
||||||
|
/** Node ingress 完整 URL */
|
||||||
|
NODE_INGRESS_URL: string;
|
||||||
|
/** Worker -> Node 共享密钥 */
|
||||||
|
NODE_INGRESS_SECRET: string;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user