feat: v1.1.0 账号化重构 + 三级 Webhook + 设置中心 + Vite 工具链

- 账号化:节点更名账号,/nodes -> /accounts;添加账号直达 Cloudflare OAuth,自动创建账号并同步账号下全部域名(catch-all 邮箱随同步创建)
- 域名:移除手工添加;点击域名『设置 Email Workers』= 部署账号级 Email Worker + Email Routing + catch-all;支持按账号筛选
- Webhook 三级模型:邮箱 -> 域名 -> 全局,收信取第一个命中者;支持自定义请求头与 key(默认 Authorization: Bearer <key>,兼做 X-WmailHub-Signature 签名);独立 webhooks 实体与 /webhooks、/keys 页面删除,Key 管理收进设置模态窗口
- 设置中心:全局 Webhook / 默认 Node Ingress 地址 / 修改密码 / API & MCP Keys(生成、撤销、删除)
- UI:Header 标题副标题紧凑化(修复溢出),LOGO 加版本号 tag(v1.1.0 取自 package.json),各列表页搜索框
- 工具链:前端构建 Bun.build -> Vite(vite 8 + @vitejs/plugin-react)
- 后端:/api/settings、POST /api/domains/:id/setup、0002 迁移(投递行固化三级目标配置);MCP 去 list_webhooks(5 工具);迁移执行器加 _migrations 记录
- 测试:44/44(新增 settings 套件、webhook 三级优先级/SSRF/签名);UI 冒烟 11/11
This commit is contained in:
bigcode
2026-09-30 11:37:38 +08:00
parent d4c1334143
commit 421cfb2f04
47 changed files with 2316 additions and 1431 deletions
+19 -12
View File
@@ -9,10 +9,15 @@ Internet → Cloudflare Email Routing → Email Worker → Node → Console →
└→ MCP └→ MCP
``` ```
- **Console**(控制平面)= 前端(React + antd v6)+ 管理 API,部署在 Cloudflare Workers,负责 Cloudflare OAuth、Node/Domain/Mailbox/Email/Webhook 管理、数据存储与 TTL 清理。 - **Console**(控制平面)= 前端(Vite + React + antd v6)+ 管理 API,部署在 Cloudflare Workers,负责 Cloudflare OAuth、账号/域名/邮箱/邮件管理、数据存储与 TTL 清理。
- **Node**(转发平面)= 纯后端转发节点(Bun),收到什么转发什么,不做业务判断。 - **Node**(转发平面)= 纯后端转发节点(Bun),收到什么转发什么,不做业务判断。
- **Email Worker** = Cloudflare 邮件入口,只把邮件事件安全转发给 Node。 - **Email Worker** = Cloudflare 邮件入口,只把邮件事件安全转发给 Node。
> **什么是「Node Ingress 地址」?** 即 Node 的邮件接收入口(如 `https://node.example.com/ingress/email`)。
> 链路是:Email Worker 在 Cloudflare 收到邮件 → POST 到该地址交给 Node → Node 用 HMAC 签名转发给 Console。
> Email Worker 部署在 Cloudflare 侧,必须被告知往哪里送,所以每个账号要配置这个地址
> (「设置 → 默认 Node Ingress 地址」给新账号用,账号详情里可单独改)。本地开发默认 `http://127.0.0.1:18788/ingress/email`。
## 本地开发(无需 Cloudflare 账号) ## 本地开发(无需 Cloudflare 账号)
```sh ```sh
@@ -44,18 +49,19 @@ curl -X POST http://127.0.0.1:18789/__email_test \
--data-binary @tests/fixtures/test-mail.eml --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 心跳上线。全程无需复制任何安装命令。 `bun run dev` 首次运行会自动:「添加账号」→ sim 模式 Cloudflare OAuth 自动创建账号 → 同步账号下全部域名 + catch-all 邮箱 → 逐域名配置 Email Workers → Node enrollment 换取身份(config.json,0600)→ 心跳上线。全程无需复制任何安装命令。
## 功能 ## 功能
- **用户登录**:用户名密码(PBKDF2),默认 admin/admin,HMAC 签名会话 cookie。 - **用户登录**:用户名密码(PBKDF2),默认 admin/admin,HMAC 签名会话 cookie;密码在「设置」中修改。
- **Node 管理**:Add Node → Cloudflare OAuth(本地 sim / 真实 OAuth)→ 自动安装,步骤可视化,凭据轮换。 - **账号管理**:「添加账号」直达 Cloudflare OAuth(本地 sim / 真实 OAuth)→ 自动创建账号并同步该账号下全部域名;凭据轮换、Ingress 修改。
- **域名**:由 OAuth 自动同步(无手工添加);点击域名「设置 Email Workers」= 部署账号级 Email Worker + 开启 Email Routing + catch-all 规则;支持按账号筛选。
- **邮件管线**:MIME 解析(postal-mime)、附件、catch-all/精确匹配、大小限额、malformed 降级入库。 - **邮件管线**:MIME 解析(postal-mime)、附件、catch-all/精确匹配、大小限额、malformed 降级入库。
- **TTL**:邮件默认保留 1 天(`expires_at`),Cron 定期清理 R2 + DB + orphan,幂等。 - **TTL**:邮件默认保留 1 天(`expires_at`),Cron 定期清理 R2 + DB + orphan,幂等。
- **Webhook**:`email.received` 事件推送,HMAC 签名头(`X-WmailHub-Signature`),指数退避重试,SSRF 防护。 - **Webhook(三级)**:配置落在 邮箱 → 域名 → 全局(设置)三级,收信时取第一个命中者转发 `email.received` 事件;支持自定义请求头与 key(key 默认以 `Authorization: Bearer <key>` 携带,并用于 `X-WmailHub-Signature` 签名);指数退避重试,SSRF 防护。
- **API v1**:`Authorization: Bearer wmh_...` 访问 `/api/v1/*`。 - **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`。 - **MCP**:`/api/v1/mcp`(JSON-RPC over HTTP),工具:`list_mailboxes` / `list_emails` / `get_email` / `create_mailbox` / `delete_mailbox`。
- **Key 管理**:API Key 与 MCP Key 在后端设置,`wmh_` 前缀 + 随机串,总长 32 位;库中只存 SHA-256,明文仅创建时展示一次。 - **Key 管理**:API Key 与 MCP Key 在「设置」模态窗口内生成/撤销/删除,`wmh_` 前缀 + 随机串,总长 32 位;库中只存 SHA-256,明文仅创建时展示一次。
## Node 协议(docs/04) ## Node 协议(docs/04)
@@ -77,7 +83,7 @@ WmailHub/
shared/ 共享协议:HMAC 签名 / Key 生成 / 加密 / SSRF 防护 / Email Worker 脚本模板 shared/ 共享协议:HMAC 签名 / Key 生成 / 加密 / SSRF 防护 / Email Worker 脚本模板
migrations/ D1 schema migrations/ D1 schema
tests/ bun test 测试 + UI 冒烟 + fixtures tests/ bun test 测试 + UI 冒烟 + fixtures
scripts/ dev 启动 / 前端构建 scripts/ dev 启动(前端构建走 vite)
docs/ 设计文档(00~10) docs/ 设计文档(00~10)
``` ```
@@ -86,10 +92,11 @@ WmailHub/
1. `bunx wrangler d1 create wmailhub` → 将 `database_id` 填入 `console/wrangler.jsonc` 1. `bunx wrangler d1 create wmailhub` → 将 `database_id` 填入 `console/wrangler.jsonc`
2. `bunx wrangler r2 bucket create wmailhub` 2. `bunx wrangler r2 bucket create wmailhub`
3. `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql` 3. `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql`
4. `cd console && bunx wrangler deploy`(前端先 `bun run build`) 4. `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0002_accounts_webhooks.sql`
5. `bunx wrangler secret put SESSION_SECRET --config console/wrangler.jsonc`(≥32 位随机串) 5. `cd console && bunx wrangler deploy`(前端先 `bun run build`)
6. Cloudflare Dashboard 创建 OAuth App(client id/secret),配置 `CF_OAUTH_CLIENT_ID/SECRET` vars 与回调 `<PUBLIC_BASE_URL>/api/oauth/cloudflare/callback`;未配置时自动进入 sim 模式(仅供本地) 6. `bunx wrangler secret put SESSION_SECRET --config console/wrangler.jsonc`(≥32 位随机串)
7. Node 部署在任意能被 Email Worker 访问的服务器:`WMAILHUB_ENROLLMENT_TOKEN=... bun node/src/main.ts`(token 在 Console「添加节点」后展示一次) 7. Cloudflare Dashboard 创建 OAuth App(client id/secret),配置 `CF_OAUTH_CLIENT_ID/SECRET` vars 与回调 `<PUBLIC_BASE_URL>/api/oauth/cloudflare/callback`;未配置时自动进入 sim 模式(仅供本地)
8. Node 部署在任意能被 Email Worker 访问的服务器:`WMAILHUB_ENROLLMENT_TOKEN=... bun node/src/main.ts`(token 在账号详情「轮换凭据」后展示一次);并在「设置 → 默认 Node Ingress 地址」填其公网地址
Cron(`*/1 * * * *`)驱动 TTL 清理与 Webhook 重试;Email Worker 由 Console provisioning 自动上传,无需手工部署。 Cron(`*/1 * * * *`)驱动 TTL 清理与 Webhook 重试;Email Worker 由 Console provisioning 自动上传,无需手工部署。
+84
View File
@@ -16,9 +16,11 @@
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.1.1",
"bun-types": "^1.2.0", "bun-types": "^1.2.0",
"playwright-core": "^1.63.0", "playwright-core": "^1.63.0",
"typescript": "^5.9.0", "typescript": "^5.9.0",
"vite": "^8.3.1",
}, },
}, },
}, },
@@ -43,6 +45,8 @@
"@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="], "@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="],
"@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="],
"@rc-component/async-validator": ["@rc-component/async-validator@6.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA=="], "@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/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=="],
@@ -129,12 +133,46 @@
"@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=="], "@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=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.11", "", { "os": "linux", "cpu": "arm" }, "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.11", "", { "os": "none", "cpu": "arm64" }, "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], "@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": ["@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=="], "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
"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=="], "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=="], "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
@@ -149,16 +187,54 @@
"dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="], "dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"hono": ["hono@4.13.11", "", {}, "sha512-/SMX/RQNJn7oNmFwH6DtwDqcrzZU2otm1FD6OJe2cWF6cfy/F8hq0XVBv7xW3FTJshRQwuBnXHDq2kNsdZnshg=="], "hono": ["hono@4.13.11", "", {}, "sha512-/SMX/RQNJn7oNmFwH6DtwDqcrzZU2otm1FD6OJe2cWF6cfy/F8hq0XVBv7xW3FTJshRQwuBnXHDq2kNsdZnshg=="],
"is-mobile": ["is-mobile@5.0.0", "", {}, "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ=="], "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=="], "json2mq": ["json2mq@0.2.0", "", { "dependencies": { "string-convert": "^0.2.0" } }, "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], "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=="], "postal-mime": ["postal-mime@2.7.6", "", {}, "sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q=="],
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], "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-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
@@ -169,22 +245,30 @@
"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=="], "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=="],
"rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="],
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "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=="], "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=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="], "string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="],
"stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
"throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="], "throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "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=="], "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"vite": ["vite@8.3.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA=="],
"@rc-component/segmented/@babel/runtime": ["@babel/runtime@8.0.5", "", {}, "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg=="], "@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=="], "@rc-component/virtual-list/@babel/runtime": ["@babel/runtime@8.0.5", "", {}, "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg=="],
+1 -2
View File
@@ -6,11 +6,10 @@
<meta name="description" content="WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台" /> <meta name="description" content="WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/assets/main.css" />
<title>WmailHub</title> <title>WmailHub</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/assets/main.js"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

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

After

Width:  |  Height:  |  Size: 606 B

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

After

Width:  |  Height:  |  Size: 1007 B

+23 -16
View File
@@ -1,45 +1,41 @@
/** 应用框架:100vh 布局(侧栏 + 顶栏大标题 + 内容区滚动),会话守卫 */ /** 应用框架:100vh 布局(侧栏 + 顶栏页面标题 + 内容区滚动),会话守卫 + 设置入口 */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { Avatar, Dropdown, Layout, Menu } from "antd"; import { Avatar, Button, Dropdown, Layout, Menu, Tooltip } from "antd";
import { import {
DashboardOutlined, DashboardOutlined,
MailOutlined, MailOutlined,
CloudServerOutlined, CloudServerOutlined,
GlobalOutlined, GlobalOutlined,
BellOutlined, SettingOutlined,
KeyOutlined,
UserOutlined, UserOutlined,
LogoutOutlined, LogoutOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { health, logout, me } from "./api.ts"; import { health, logout, me } from "./api.ts";
import { SettingsModal } from "./components/SettingsModal.tsx";
const { Sider, Header, Content } = Layout; const { Sider, Header, Content } = Layout;
/** # 成员变量: 侧栏菜单项 **/ /** # 成员变量: 侧栏菜单项 **/
const MENU_ITEMS = [ const MENU_ITEMS = [
{ key: "/", icon: <DashboardOutlined />, label: "概览" }, { key: "/", icon: <DashboardOutlined />, label: "概览" },
{ key: "/nodes", icon: <CloudServerOutlined />, label: "节点 Nodes" }, { key: "/accounts", icon: <CloudServerOutlined />, label: "账号 Accounts" },
{ key: "/domains", icon: <GlobalOutlined />, label: "域名 Domains" }, { key: "/domains", icon: <GlobalOutlined />, label: "域名 Domains" },
{ key: "/mailboxes", icon: <MailOutlined />, label: "邮箱 Mailboxes" }, { key: "/mailboxes", icon: <MailOutlined />, label: "邮箱 Mailboxes" },
{ key: "/emails", icon: <MailOutlined />, label: "邮件 Emails" }, { 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 }> = { const PAGE_META: Record<string, { title: string; subtitle: string }> = {
"/": { title: "概览", subtitle: "节点、邮件与投递总览" }, "/": { title: "概览", subtitle: "账号、域名与邮件总览" },
"/nodes": { title: "节点管理", subtitle: "通过 Cloudflare OAuth 自动安装的转发节点" }, "/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号" },
"/domains": { title: "域名管理", subtitle: "接入 Email Routing 的收件域名" }, "/domains": { title: "域名", subtitle: "OAuth 自动同步的收件域名与 Email Workers" },
"/mailboxes": { title: "邮箱管理", subtitle: "收件地址与保留策略" }, "/mailboxes": { title: "邮箱", subtitle: "收件地址、保留策略与邮箱级 Webhook" },
"/emails": { title: "邮件", subtitle: "已接收邮件(默认保留 1 天)" }, "/emails": { title: "邮件", subtitle: "已接收邮件(默认保留 1 天)" },
"/webhooks": { title: "Webhooks", subtitle: "邮件事件推送与投递日志" },
"/keys": { title: "API / MCP Keys", subtitle: "wmh_ 前缀凭据,仅创建时可见" },
}; };
/** # 函数 /** # 函数
* 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 版本号(package.json 来源) * 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 设置入口;侧栏品牌带版本号
*/ */
export function App() { export function App() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -47,6 +43,7 @@ export function App() {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [version, setVersion] = useState(""); const [version, setVersion] = useState("");
const [settingsOpen, setSettingsOpen] = useState(false);
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
@@ -70,6 +67,8 @@ export function App() {
<div className="brand"> <div className="brand">
<img src="/logo.svg" alt="WmailHub" /> <img src="/logo.svg" alt="WmailHub" />
<span className="name">WmailHub</span> <span className="name">WmailHub</span>
{/* 版本号小一号、居上对齐(来源 package.json) */}
{version && <span className="brand-version">v{version}</span>}
</div> </div>
<Menu <Menu
mode="inline" mode="inline"
@@ -81,12 +80,19 @@ export function App() {
</Sider> </Sider>
<Layout> <Layout>
<Header className="app-header"> <Header className="app-header">
<div> <div className="header-meta">
<div className="title">{meta.title}</div> <div className="title">{meta.title}</div>
<div className="subtitle">{meta.subtitle}</div> <div className="subtitle">{meta.subtitle}</div>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}> <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<span className="muted mono">v{version}</span> <Tooltip title="设置(全局 Webhook / Ingress / 密码 / API & MCP Keys)">
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => setSettingsOpen(true)}
aria-label="设置"
/>
</Tooltip>
{/* Dropdown 子元素必须是 DOM 元素(antd v6 坑),所以外包 div */} {/* Dropdown 子元素必须是 DOM 元素(antd v6 坑),所以外包 div */}
<div> <div>
<Dropdown <Dropdown
@@ -116,6 +122,7 @@ export function App() {
<Outlet /> <Outlet />
</Content> </Content>
</Layout> </Layout>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</Layout> </Layout>
); );
} }
+76 -73
View File
@@ -12,7 +12,7 @@ export function setUnauthorizedHandler(fn: () => void): void {
/** # 函数 /** # 函数
* 通用请求封装 * 通用请求封装
* 参数: method/url/body(JSON)/query * 参数: method/url/body(JSON)
* 返回值: 解析后的 JSON;非 2xx 抛 ApiError * 返回值: 解析后的 JSON;非 2xx 抛 ApiError
*/ */
export async function api<T = unknown>( export async function api<T = unknown>(
@@ -68,7 +68,7 @@ export function qs(params: Record<string, string | number | undefined>): string
return s ? `?${s}` : ""; return s ? `?${s}` : "";
} }
/* ---- 具体接口封装 ---- */ /* ---- 会话 ---- */
/** 登录 */ /** 登录 */
export const login = (username: string, password: string) => export const login = (username: string, password: string) =>
@@ -83,16 +83,19 @@ export const me = () => api<{ user: { id: string; username: string } }>("/api/au
/** 健康检查(含版本号) */ /** 健康检查(含版本号) */
export const health = () => api<{ ok: boolean; name: string; version: string }>("/api/health"); export const health = () => api<{ ok: boolean; name: string; version: string }>("/api/health");
/* ---- 概览 ---- */
/** Dashboard 统计 */ /** Dashboard 统计 */
export const stats = () => export const stats = () =>
api<{ api<{
nodes_online: number; accounts_online: number;
nodes_total: number; accounts_total: number;
domains: number; domains: number;
domains_active: number;
mailboxes: number; mailboxes: number;
emails: number; emails: number;
emails_expired: number; emails_expired: number;
webhooks: number; global_webhook: number;
deliveries_pending: number; deliveries_pending: number;
oauth_connections: number; oauth_connections: number;
recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[]; recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[];
@@ -101,9 +104,9 @@ export const stats = () =>
/** 手动触发 Cron */ /** 手动触发 Cron */
export const runCron = () => api<{ ok: boolean; stats: Record<string, number | string[]> }>("/api/admin/cron/run", "POST"); export const runCron = () => api<{ ok: boolean; stats: Record<string, number | string[]> }>("/api/admin/cron/run", "POST");
/* Nodes */ /* ---- 账号(原节点) ---- */
export interface NodeRow { export interface AccountRow {
id: string; id: string;
name: string; name: string;
cloudflare_account_id: string | null; cloudflare_account_id: string | null;
@@ -114,62 +117,79 @@ export interface NodeRow {
created_at: string; created_at: string;
} }
export const listNodes = () => api<{ items: NodeRow[]; total: number }>("/api/nodes"); export const listAccounts = () => api<{ items: AccountRow[]; total: number }>("/api/accounts");
export const createNode = (name: string, ingress_url: string) => export interface AccountDetail {
api<{ ok: boolean; node_id: string; enrollment_token: string; ingress_secret: string; worker_name: string }>( account: AccountRow & { enrollment_expires_at: string | null };
"/api/nodes",
"POST",
{ name, ingress_url },
);
export interface NodeDetail {
node: NodeRow & { enrollment_expires_at: string | null };
steps: Record<string, { status: string; error?: string }>; steps: Record<string, { status: string; error?: string }>;
oauth: { id: string; account_id: string | null; created_at: string } | null; oauth: { id: string; account_id: string | null; created_at: string } | null;
domains: { id: string; name: string; status: string; worker_id: 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 getAccount = (id: string) => api<AccountDetail>(`/api/accounts/${id}`);
export const deleteNode = (id: string) => api(`/api/nodes/${id}`, "DELETE"); export const updateAccount = (id: string, body: { name?: string; ingress_url?: string }) =>
api(`/api/accounts/${id}`, "PATCH", body);
export const rotateNode = (id: string) => export const deleteAccount = (id: string) => api(`/api/accounts/${id}`, "DELETE");
api<{ ok: boolean; enrollment_token: string }>(`/api/nodes/${id}/rotate`, "POST");
export const getNodeIngressSecret = (id: string) => export const rotateAccount = (id: string) =>
api<{ ingress_secret: string }>(`/api/nodes/${id}/ingress-secret`); api<{ ok: boolean; enrollment_token: string }>(`/api/accounts/${id}/rotate`, "POST");
/* OAuth */ export const getAccountIngressSecret = (id: string) =>
api<{ ingress_secret: string }>(`/api/accounts/${id}/ingress-secret`);
/* ---- OAuth ---- */
export const oauthMode = () => api<{ mode: "cloudflare" | "sim" }>("/api/oauth/mode"); export const oauthMode = () => api<{ mode: "cloudflare" | "sim" }>("/api/oauth/mode");
export const oauthStart = (nodeId: string) => /** 开始授权:nodeId 缺省 = 自动创建账号 */
api<{ url: string; state: string }>(`/api/oauth/cloudflare/start?node_id=${encodeURIComponent(nodeId)}`); export const oauthStart = (nodeId?: string) =>
api<{ url: string; state: string }>(
`/api/oauth/cloudflare/start${nodeId ? `?node_id=${encodeURIComponent(nodeId)}` : ""}`,
);
export const oauthSimApprove = (state: string) => export const oauthSimApprove = (state: string) =>
api<{ ok: boolean; error?: string; node_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state }); api<{ ok: boolean; error?: string; node_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state });
/* Domains */ /* ---- 域名 ---- */
export interface DomainRow { export interface DomainRow {
id: string; id: string;
name: string; name: string;
status: string; status: string;
setup_error: string | null;
worker_id: string | null;
zone_id: string | null;
node_id: string | null; node_id: string | null;
node_name: string | null; node_name: string | null;
worker_id: string | null; webhook_url: string | null;
webhook_headers: string | null;
webhook_has_key: number;
mailbox_count: number; mailbox_count: number;
created_at: string; created_at: string;
} }
export const listDomains = () => api<{ items: DomainRow[]; total: number }>("/api/domains"); 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 listDomainAccounts = () => api<{ items: { id: string; name: string }[] }>("/api/domains/filters/accounts");
/** 为域名配置 Email Workers(Worker + Email Routing + catch-all) */
export const setupDomain = (id: string) =>
api<{ ok: boolean; error?: string }>(`/api/domains/${id}/setup`, "POST");
/** 保存域名级 Webhook(url 空串清除;key undefined 保留原值) */
export const putDomainWebhook = (
id: string,
body: { url: string; headers?: Record<string, string>; key?: string },
) => api(`/api/domains/${id}/webhook`, "PUT", body);
export const deleteDomainWebhook = (id: string) => api(`/api/domains/${id}/webhook`, "DELETE");
export const deleteDomain = (id: string) => api(`/api/domains/${id}`, "DELETE"); export const deleteDomain = (id: string) => api(`/api/domains/${id}`, "DELETE");
/* Mailboxes */ /* ---- 邮箱 ---- */
export interface MailboxRow { export interface MailboxRow {
id: string; id: string;
@@ -179,6 +199,9 @@ export interface MailboxRow {
retention_seconds: number | null; retention_seconds: number | null;
domain_id: string; domain_id: string;
domain_name: string; domain_name: string;
webhook_url: string | null;
webhook_headers: string | null;
webhook_has_key: number;
email_count: number; email_count: number;
created_at: string; created_at: string;
} }
@@ -192,9 +215,17 @@ export const createMailbox = (body: { domain_id: string; address: string; retent
export const patchMailbox = (id: string, body: { status?: string; retention_seconds?: number | null }) => export const patchMailbox = (id: string, body: { status?: string; retention_seconds?: number | null }) =>
api(`/api/mailboxes/${id}`, "PATCH", body); api(`/api/mailboxes/${id}`, "PATCH", body);
/** 保存邮箱级 Webhook(url 空串清除;key undefined 保留原值) */
export const putMailboxWebhook = (
id: string,
body: { url: string; headers?: Record<string, string>; key?: string },
) => api(`/api/mailboxes/${id}/webhook`, "PUT", body);
export const deleteMailboxWebhook = (id: string) => api(`/api/mailboxes/${id}/webhook`, "DELETE");
export const deleteMailbox = (id: string) => api(`/api/mailboxes/${id}`, "DELETE"); export const deleteMailbox = (id: string) => api(`/api/mailboxes/${id}`, "DELETE");
/* Emails */ /* ---- 邮件 ---- */
export interface EmailRow { export interface EmailRow {
id: string; id: string;
@@ -222,54 +253,26 @@ export const getEmail = (id: string) => api<EmailDetail>(`/api/emails/${id}`);
export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE"); export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
/* Webhooks */ /* ---- 设置 ---- */
export interface WebhookRow { export interface SettingsInfo {
id: string; username: string;
name: string; default_ingress_url: string;
endpoint: string; webhook: { url: string; headers: Record<string, string>; has_key: boolean; key_last4: string };
enabled: number;
delivery_count: number;
success_count: number;
created_at: string;
} }
export const listWebhooks = () => api<{ items: WebhookRow[]; total: number }>("/api/webhooks"); export const getSettings = () => api<SettingsInfo>("/api/settings");
export const createWebhook = (name: string, endpoint: string) => /** 保存设置;webhook.key:undefined 保留原值、空串清除、非空设置新 key */
api<{ ok: boolean; id: string; secret: string }>("/api/webhooks", "POST", { name, endpoint }); export const putSettings = (body: {
default_ingress_url?: string;
webhook?: { url: string; headers?: Record<string, string>; key?: string };
}) => api("/api/settings", "PUT", body);
export const patchWebhook = (id: string, body: { name?: string; endpoint?: string; enabled?: boolean }) => export const changePassword = (old_password: string, new_password: string) =>
api(`/api/webhooks/${id}`, "PATCH", body); api("/api/settings/password", "PUT", { old_password, new_password });
export const rotateWebhookSecret = (id: string) => /* ---- API/MCP Keys(设置模态窗口内使用) ---- */
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 { export interface KeyRow {
id: string; id: string;
@@ -0,0 +1,345 @@
/**
* 设置模态窗口(v1.1.0):
* - 基本设置:全局 Webhook(url/自定义头/key)+ 默认 Node Ingress 地址 + 修改密码
* - API Keys / MCP Keys:wmh_ 凭据的生成、撤销、删除(原 /keys 页面收拢于此)
*/
import { useCallback, useEffect, useState } from "react";
import {
Alert,
Button,
Form,
Input,
Modal,
Space,
Table,
Tabs,
Tag,
Typography,
App,
Popconfirm,
} from "antd";
import { PlusOutlined } from "@ant-design/icons";
import {
changePassword,
createKey,
deleteKey,
getSettings,
listKeys,
putSettings,
revokeKey,
type KeyRow,
type SettingsInfo,
} from "../api.ts";
/** # 函数
* 设置模态窗口组件
*/
export function SettingsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { message } = App.useApp();
const [info, setInfo] = useState<SettingsInfo | null>(null);
const load = useCallback(async () => {
try {
setInfo(await getSettings());
} catch (err) {
message.error(err instanceof Error ? err.message : "读取设置失败");
}
}, [message]);
useEffect(() => {
if (open) void load();
}, [open, load]);
return (
<Modal title="设置" open={open} onCancel={onClose} footer={null} width={760} destroyOnHidden>
<Tabs
items={[
{ key: "general", label: "基本设置", children: info ? <GeneralTab info={info} onSaved={load} /> : null },
{ key: "api", label: "API Keys", children: <KeysTab kind="api" /> },
{ key: "mcp", label: "MCP Keys", children: <KeysTab kind="mcp" /> },
]}
/>
</Modal>
);
}
/** # 成员变量: Webhook 配置表单值 **/
interface WebhookFormValues {
url: string;
headers: string;
key?: string;
}
/** # 函数
* 基本设置:全局 Webhook + 默认 Node Ingress + 修改密码
*/
function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
const { message } = App.useApp();
const [webhookForm] = Form.useForm<WebhookFormValues>();
const [ingressForm] = Form.useForm<{ ingress: string }>();
const [pwdForm] = Form.useForm<{
old_password: string;
new_password: string;
confirm: string;
}>();
const [saving, setSaving] = useState(false);
useEffect(() => {
webhookForm.setFieldsValue({
url: info.webhook.url ?? "",
headers: info.webhook.url ? JSON.stringify(info.webhook.headers ?? {}, null, 2) : "",
});
}, [info, webhookForm]);
/** 保存全局 Webhook 与默认 Ingress */
const saveWebhook = async () => {
const values = await webhookForm.validateFields();
let headers: Record<string, string> = {};
if (values.headers.trim()) {
try {
headers = JSON.parse(values.headers) as Record<string, string>;
} catch {
message.error("自定义请求头必须是合法 JSON 对象");
return;
}
}
setSaving(true);
try {
await putSettings({
default_ingress_url: info.default_ingress_url,
webhook: { url: values.url.trim(), headers, key: values.key },
});
message.success("全局 Webhook 已保存");
webhookForm.setFieldValue("key", undefined);
onSaved();
} catch (err) {
message.error(err instanceof Error ? err.message : "保存失败");
} finally {
setSaving(false);
}
};
/** 保存默认 Node Ingress 地址 */
const saveIngress = async () => {
const values = await ingressForm.validateFields();
try {
await putSettings({ default_ingress_url: values.ingress.trim() });
message.success("默认 Node Ingress 地址已保存");
onSaved();
} catch (err) {
message.error(err instanceof Error ? err.message : "保存失败");
}
};
/** 修改密码 */
const savePassword = async () => {
const values = await pwdForm.validateFields();
try {
await changePassword(values.old_password, values.new_password);
message.success("密码已更新");
pwdForm.resetFields();
} catch (err) {
message.error(err instanceof Error ? err.message : "修改失败");
}
};
return (
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<Form form={webhookForm} layout="vertical">
<Typography.Title level={5}>全局 Webhook</Typography.Title>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="收到邮件时按 邮箱 → 域名 → 全局 的顺序取第一个已配置的 Webhook 转发;此处为兜底的全局配置。留空 URL 表示停用全局 Webhook。"
/>
<Form.Item name="url" label="Webhook URL">
<Input placeholder="https://hooks.example.com/wmail" allowClear />
</Form.Item>
<Form.Item
name="headers"
label="自定义请求头(JSON 对象,可选)"
extra='示例:{"X-Token":"abc"};配置了 key 时默认携带 Authorization: Bearer <key>,头里的 Authorization 优先'
>
<Input.TextArea rows={2} placeholder='{"X-Custom":"1"}' />
</Form.Item>
<Form.Item
name="key"
label="Key"
extra={
info.webhook.has_key
? `已设置 key(****${info.webhook.key_last4}),用于签名(X-WmailHub-Signature)与默认 Authorization;留空保留现有,填空格清除`
: "可选;默认以 Authorization: Bearer <key> 携带,并用于 Webhook 签名"
}
>
<Input.Password placeholder="留空保留现有 key" autoComplete="new-password" />
</Form.Item>
<Button type="primary" loading={saving} onClick={() => void saveWebhook()}>
保存 Webhook
</Button>
</Form>
<Form form={ingressForm} layout="vertical" initialValues={{ ingress: info.default_ingress_url }}>
<Typography.Title level={5}>默认 Node Ingress 地址</Typography.Title>
<Typography.Text type="secondary" style={{ display: "block", marginBottom: 8 }}>
「添加账号」创建的账号默认使用的 Node Ingress 地址:即 Email Worker 收到邮件后转发到的 Node 接收入口
(Node 再签名转发给 Console)。生产环境填 Node 的公网 HTTPS 地址。
</Typography.Text>
<Space.Compact style={{ width: "100%" }}>
<Form.Item name="ingress" noStyle rules={[{ required: true, message: "请输入地址" }]}>
<Input placeholder="http://127.0.0.1:18788/ingress/email" />
</Form.Item>
<Button onClick={() => void saveIngress()}>保存</Button>
</Space.Compact>
</Form>
<Form form={pwdForm} layout="vertical" onFinish={() => void savePassword()}>
<Typography.Title level={5}>修改密码</Typography.Title>
<Form.Item name="old_password" label="原密码" rules={[{ required: true, message: "请输入原密码" }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item name="new_password" label="新密码(至少 6 位)" rules={[{ required: true, min: 6, message: "至少 6 位" }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
name="confirm"
label="确认新密码"
dependencies={["new_password"]}
rules={[
{ required: true, message: "请再次输入" },
({ getFieldValue }) => ({
validator: (_, v) =>
!v || v === getFieldValue("new_password")
? Promise.resolve()
: Promise.reject(new Error("两次输入不一致")),
}),
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Button type="primary" htmlType="submit">
更新密码
</Button>
</Form>
</Space>
);
}
/** # 函数
* API/MCP Key 管理表格
*/
function KeysTab({ kind }: { kind: "api" | "mcp" }) {
const { message, modal } = App.useApp();
const [items, setItems] = useState<KeyRow[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listKeys()).items.filter((k) => k.kind === kind));
} finally {
setLoading(false);
}
}, [kind]);
useEffect(() => {
void load();
}, [load]);
const filtered = items.filter((k) =>
`${k.name} ${k.key_last4}`.toLowerCase().includes(q.trim().toLowerCase()),
);
/** 生成新 key:明文仅展示一次 */
const create = async () => {
try {
const res = await createKey(kind);
modal.info({
title: `${kind === "api" ? "API" : "MCP"} Key 已生成(仅显示一次)`,
content: (
<div>
<Typography.Paragraph copyable className="mono" style={{ marginBottom: 4 }}>
{res.key}
</Typography.Paragraph>
<Typography.Text type="secondary">请立即保存;关闭后无法再次查看。</Typography.Text>
</div>
),
});
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "生成失败");
}
};
return (
<div>
<div className="page-toolbar">
<Input
allowClear
placeholder="搜索名称 / 尾号"
style={{ width: 220 }}
onChange={(e) => setQ(e.target.value)}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => void create()}>
生成 {kind === "api" ? "API" : "MCP"} Key
</Button>
</div>
<Table
rowKey="id"
size="small"
loading={loading}
dataSource={filtered}
pagination={false}
columns={[
{ title: "名称", dataIndex: "name", ellipsis: true },
{ title: "尾号", dataIndex: "key_last4", width: 80, render: (v: string) => <span className="mono">****{v}</span> },
{
title: "状态",
dataIndex: "revoked_at",
width: 90,
render: (v: string | null) => (v ? <Tag color="error">已撤销</Tag> : <Tag color="success">有效</Tag>),
},
{ title: "创建时间", dataIndex: "created_at", width: 165, render: (v: string) => new Date(v).toLocaleString() },
{
title: "最近使用",
dataIndex: "last_used_at",
width: 165,
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
},
{
title: "操作",
width: 130,
render: (_: unknown, row: KeyRow) => (
<Space>
{!row.revoked_at && (
<Popconfirm
title="撤销后使用该 Key 的请求将被拒绝,确认?"
onConfirm={async () => {
await revokeKey(row.id);
message.success("已撤销");
void load();
}}
>
<Button size="small">撤销</Button>
</Popconfirm>
)}
<Button
size="small"
danger
onClick={async () => {
await deleteKey(row.id);
message.success("已删除");
void load();
}}
>
删除
</Button>
</Space>
),
},
]}
/>
</div>
);
}
@@ -0,0 +1,134 @@
/**
* Webhook 配置表单(域名级/邮箱级共用;全局版本在设置模态窗口内)。
* key 语义:留空保留现有;填空格清除;非空设置新 key。
*/
import { useEffect, useState } from "react";
import { Button, Form, Input, Space, App } from "antd";
import { putDomainWebhook, putMailboxWebhook } from "../api.ts";
/** # 成员变量: 表单当前值(由父组件传入) **/
export interface WebhookValue {
url: string | null;
headers: string | null;
hasKey: boolean;
}
/** # 函数
* 解析 headers JSON 串为对象(失败返回 {})
*/
export function parseHeaders(s: string | null): Record<string, string> {
if (!s) return {};
try {
const v = JSON.parse(s);
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, string>) : {};
} catch {
return {};
}
}
/** # 函数
* Webhook 配置表单组件
* 参数: scope - "domain" | "mailbox";targetId - 域名/邮箱 ID;value - 当前配置;onSaved - 保存后回调
*/
export function WebhookForm({
scope,
targetId,
value,
onSaved,
}: {
scope: "domain" | "mailbox";
targetId: string;
value: WebhookValue;
onSaved: () => void;
}) {
const { message } = App.useApp();
const [form] = Form.useForm<{ url: string; headers: string; key?: string }>();
const [saving, setSaving] = useState(false);
useEffect(() => {
form.setFieldsValue({
url: value.url ?? "",
headers: value.url ? JSON.stringify(parseHeaders(value.headers), null, 2) : "",
});
}, [value, form, targetId]);
const save = async () => {
const values = await form.validateFields();
let headers: Record<string, string> = {};
if (values.headers.trim()) {
try {
headers = JSON.parse(values.headers) as Record<string, string>;
} catch {
message.error("自定义请求头必须是合法 JSON 对象");
return;
}
}
const key = values.key ?? undefined;
// "填空格清除 key":把单个空格翻译成空串
const normalizedKey = key !== undefined && key.trim() === "" && key.length > 0 ? "" : key;
setSaving(true);
try {
const body = { url: values.url.trim(), headers, key: normalizedKey };
if (scope === "domain") await putDomainWebhook(targetId, body);
else await putMailboxWebhook(targetId, body);
message.success("Webhook 已保存");
form.setFieldValue("key", undefined);
onSaved();
} catch (err) {
message.error(err instanceof Error ? err.message : "保存失败");
} finally {
setSaving(false);
}
};
const clear = async () => {
setSaving(true);
try {
if (scope === "domain") await putDomainWebhook(targetId, { url: "" });
else await putMailboxWebhook(targetId, { url: "" });
message.success("已清除该级 Webhook(将回退到上一级配置)");
form.resetFields();
onSaved();
} catch (err) {
message.error(err instanceof Error ? err.message : "清除失败");
} finally {
setSaving(false);
}
};
return (
<Form form={form} layout="vertical">
<Form.Item name="url" label="Webhook URL" extra="留空并保存 = 清除该级配置">
<Input placeholder="https://hooks.example.com/wmail" allowClear />
</Form.Item>
<Form.Item
name="headers"
label="自定义请求头(JSON 对象,可选)"
extra='示例:{"X-Token":"abc"};配置了 key 时默认携带 Authorization: Bearer <key>'
>
<Input.TextArea rows={2} placeholder='{"X-Custom":"1"}' />
</Form.Item>
<Form.Item
name="key"
label="Key"
extra={
value.hasKey
? "已设置 key(****尾号见保存后提示);留空保留,填空格清除"
: "可选;默认以 Authorization: Bearer <key> 携带,并用于投递签名"
}
>
<Input.Password placeholder="留空保留现有 key" autoComplete="new-password" />
</Form.Item>
<Space>
<Button type="primary" loading={saving} onClick={() => void save()}>
保存
</Button>
{value.url && (
<Button danger onClick={() => void clear()}>
清除该级配置
</Button>
)}
</Space>
</Form>
);
}
+3 -7
View File
@@ -1,4 +1,4 @@
/** 前端入口:antd v6 + react-router,路由守卫由 App 内 authGate 状态控制 */ /** 前端入口:antd v6 + react-router(Vite 构建) */
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { ConfigProvider, App as AntdApp } from "antd"; import { ConfigProvider, App as AntdApp } from "antd";
@@ -10,12 +10,10 @@ import { App } from "./App.tsx";
import { LoginPage } from "./pages/Login.tsx"; import { LoginPage } from "./pages/Login.tsx";
import { OAuthSimPage } from "./pages/OAuthSim.tsx"; import { OAuthSimPage } from "./pages/OAuthSim.tsx";
import { DashboardPage } from "./pages/Dashboard.tsx"; import { DashboardPage } from "./pages/Dashboard.tsx";
import { NodesPage } from "./pages/Nodes.tsx"; import { AccountsPage } from "./pages/Accounts.tsx";
import { DomainsPage } from "./pages/Domains.tsx"; import { DomainsPage } from "./pages/Domains.tsx";
import { MailboxesPage } from "./pages/Mailboxes.tsx"; import { MailboxesPage } from "./pages/Mailboxes.tsx";
import { EmailsPage } from "./pages/Emails.tsx"; import { EmailsPage } from "./pages/Emails.tsx";
import { WebhooksPage } from "./pages/Webhooks.tsx";
import { KeysPage } from "./pages/Keys.tsx";
import { setUnauthorizedHandler } from "./api.ts"; import { setUnauthorizedHandler } from "./api.ts";
/** # 成员变量: 路由表 **/ /** # 成员变量: 路由表 **/
@@ -27,12 +25,10 @@ const router = createBrowserRouter([
element: <App />, element: <App />,
children: [ children: [
{ index: true, element: <DashboardPage /> }, { index: true, element: <DashboardPage /> },
{ path: "nodes", element: <NodesPage /> }, { path: "accounts", element: <AccountsPage /> },
{ path: "domains", element: <DomainsPage /> }, { path: "domains", element: <DomainsPage /> },
{ path: "mailboxes", element: <MailboxesPage /> }, { path: "mailboxes", element: <MailboxesPage /> },
{ path: "emails", element: <EmailsPage /> }, { path: "emails", element: <EmailsPage /> },
{ path: "webhooks", element: <WebhooksPage /> },
{ path: "keys", element: <KeysPage /> },
], ],
}, },
]); ]);
@@ -1,36 +1,33 @@
/** 节点页:Add Node(OAuth 自动安装)、安装进度、凭据轮换 */ /** 账号页(原节点):添加账号直达 Cloudflare OAuth,授权后自动同步域名;搜索、详情、凭据管理 */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { import {
Button, Button,
Card, Card,
Drawer, Drawer,
Form,
Input, Input,
Modal,
Space, Space,
Table, Table,
Tag, Tag,
Timeline, Timeline,
Typography, Typography,
App, App,
Alert,
} from "antd"; } from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons"; import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { import {
createNode, deleteAccount,
deleteNode, getAccount,
getNode, getAccountIngressSecret,
getNodeIngressSecret, listAccounts,
listNodes,
oauthMode, oauthMode,
oauthStart, oauthStart,
rotateNode, rotateAccount,
type NodeDetail, updateAccount,
type NodeRow, type AccountDetail,
type AccountRow,
} from "../api.ts"; } from "../api.ts";
/** # 成员变量: 节点状态 -> Tag 颜色 **/ /** # 成员变量: 账号状态 -> Tag 颜色 **/
const STATUS_COLOR: Record<string, string> = { const STATUS_COLOR: Record<string, string> = {
pending_oauth: "orange", pending_oauth: "orange",
provisioning: "processing", provisioning: "processing",
@@ -44,29 +41,26 @@ const STEP_LABEL: Record<string, string> = {
oauth: "Cloudflare OAuth 授权", oauth: "Cloudflare OAuth 授权",
identity: "Node identity 与 credential", identity: "Node identity 与 credential",
discover: "Account / Zone 发现", discover: "Account / Zone 发现",
email_worker: "Email Worker 创建/更新", domains: "域名与 catch-all 邮箱同步",
email_routing: "Email Routing 配置",
domains: "Domain 创建",
mailboxes: "Mailbox 创建",
}; };
/** # 函数 /** # 函数
* 节点页组件 * 账号页组件
*/ */
export function NodesPage() { export function AccountsPage() {
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
const navigate = useNavigate();
const [params] = useSearchParams(); const [params] = useSearchParams();
const [items, setItems] = useState<NodeRow[]>([]); const [items, setItems] = useState<AccountRow[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [addOpen, setAddOpen] = useState(false); const [q, setQ] = useState("");
const [form] = Form.useForm(); const [detail, setDetail] = useState<AccountDetail | null>(null);
const [detail, setDetail] = useState<NodeDetail | null>(null);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
setItems((await listNodes()).items); setItems((await listAccounts()).items);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -79,7 +73,7 @@ export function NodesPage() {
if (provisionId) { if (provisionId) {
void (async () => { void (async () => {
try { try {
setDetail(await getNode(provisionId)); setDetail(await getAccount(provisionId));
setDetailOpen(true); setDetailOpen(true);
} catch { } catch {
// ignore // ignore
@@ -88,43 +82,53 @@ export function NodesPage() {
} }
}, [load, params]); }, [load, params]);
/** Add Node 提交:创建节点后进入 OAuth */ const filtered = useMemo(
const submitAdd = async () => { () =>
const values = await form.validateFields(); items.filter((row) =>
const created = await createNode(values.name, values.ingress_url || undefined); `${row.name} ${row.status} ${row.cloudflare_account_id ?? ""}`
setAddOpen(false); .toLowerCase()
form.resetFields(); .includes(q.trim().toLowerCase()),
message.success("节点已创建,开始 Cloudflare 授权"); ),
await startOAuth(created.node_id); [items, q],
}; );
/** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页 */ /** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页。
const startOAuth = async (nodeId: string) => { * accountId 缺省 = 服务端自动创建账号。 */
const startOAuth = async (accountId?: string) => {
try {
const mode = (await oauthMode()).mode; const mode = (await oauthMode()).mode;
const { url } = await oauthStart(nodeId); const { url } = await oauthStart(accountId);
if (mode === "cloudflare") { if (mode === "cloudflare") window.location.href = url;
window.location.href = url; else navigate(url); // /oauth/sim/authorize?state=...
} else { } catch (err) {
window.location.href = url; // /oauth/sim/authorize?state=... message.error(err instanceof Error ? err.message : "发起授权失败");
} }
}; };
const openDetail = async (id: string) => { const openDetail = async (id: string) => {
setDetail(await getNode(id)); setDetail(await getAccount(id));
setDetailOpen(true); setDetailOpen(true);
}; };
return ( return (
<div> <div>
<div className="page-toolbar"> <div className="page-toolbar">
<div className="filters">
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索名称 / 状态 / Cloudflare 账户"
style={{ width: 280 }}
onChange={(e) => setQ(e.target.value)}
/>
<span className="muted"> <span className="muted">
点击「添加节点」后完成 Cloudflare OAuth,系统自动安装 Email Worker / Email Routing / Domain / Mailbox, 点击「添加账号」完成 Cloudflare OAuth,自动同步账号下全部域名;在域名列表逐域名开启 Email Workers。
全程无需在服务器执行任何安装命令。
</span> </span>
</div>
<Space> <Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} /> <Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddOpen(true)}> <Button type="primary" icon={<PlusOutlined />} onClick={() => void startOAuth()}>
添加节点 添加账号
</Button> </Button>
</Space> </Space>
</div> </div>
@@ -133,7 +137,7 @@ export function NodesPage() {
<Table <Table
rowKey="id" rowKey="id"
loading={loading} loading={loading}
dataSource={items} dataSource={filtered}
pagination={false} pagination={false}
onRow={(row) => ({ onClick: () => void openDetail(row.id), style: { cursor: "pointer" } })} onRow={(row) => ({ onClick: () => void openDetail(row.id), style: { cursor: "pointer" } })}
columns={[ columns={[
@@ -141,21 +145,22 @@ export function NodesPage() {
{ {
title: "状态", title: "状态",
dataIndex: "status", dataIndex: "status",
width: 120, width: 130,
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>, render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
}, },
{ title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", render: (v: string | null) => v ?? "-" }, { title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", ellipsis: true, render: (v: string | null) => v ?? "-" },
{ title: "Ingress", dataIndex: "ingress_url", ellipsis: true, render: (v: string) => <span className="mono">{v}</span> },
{ title: "版本", dataIndex: "version", width: 90, render: (v: string | null) => v ?? "-" }, { title: "版本", dataIndex: "version", width: 90, render: (v: string | null) => v ?? "-" },
{ {
title: "最近心跳", title: "最近心跳",
dataIndex: "last_seen_at", dataIndex: "last_seen_at",
width: 190, width: 170,
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"), render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
}, },
{ {
title: "操作", title: "操作",
width: 150, width: 150,
render: (_: unknown, row: NodeRow) => ( render: (_: unknown, row: AccountRow) => (
<Space> <Space>
<Button <Button
size="small" size="small"
@@ -173,10 +178,10 @@ export function NodesPage() {
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
modal.confirm({ modal.confirm({
title: `删除节点 ${row.name}?`, title: `删除账号 ${row.name}?`,
content: "将撤销节点凭据并标记为 revoked。", content: "将撤销账号凭据并标记为 revoked;Node 内部协议将立即失效。",
onOk: async () => { onOk: async () => {
await deleteNode(row.id); await deleteAccount(row.id);
message.success("已删除"); message.success("已删除");
void load(); void load();
}, },
@@ -192,14 +197,12 @@ export function NodesPage() {
/> />
</Card> </Card>
<AddNodeModal open={addOpen} form={form} onOk={() => void submitAdd()} onCancel={() => setAddOpen(false)} /> <AccountDetailDrawer
<NodeDetailDrawer
detail={detail} detail={detail}
open={detailOpen} open={detailOpen}
onClose={() => setDetailOpen(false)} onClose={() => setDetailOpen(false)}
onReload={async () => { onReload={async () => {
if (detail) setDetail(await getNode(detail.node.id)); if (detail) setDetail(await getAccount(detail.account.id));
void load(); void load();
}} }}
/> />
@@ -208,74 +211,35 @@ export function NodesPage() {
} }
/** # 函数 /** # 函数
* 添加节点弹窗 * 账号详情抽屉:安装步骤时间线 + 域名 + ingress/凭据管理
*/ */
function AddNodeModal({ function AccountDetailDrawer({
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, detail,
open, open,
onClose, onClose,
onReload, onReload,
}: { }: {
detail: NodeDetail | null; detail: AccountDetail | null;
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onReload: () => void; onReload: () => void;
}) { }) {
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
const [showSecret, setShowSecret] = useState(false); const [ingress, setIngress] = useState("");
const [name, setName] = useState("");
useEffect(() => {
if (detail) {
setIngress(detail.account.ingress_url);
setName(detail.account.name);
}
}, [detail]);
if (!detail) return null; if (!detail) return null;
const steps = detail.steps; const steps = detail.steps;
return ( return (
<Drawer title={`节点:${detail.node.name}`} size="large" open={open} onClose={onClose}> <Drawer title={`账号:${detail.account.name}`} size="large" open={open} onClose={onClose}>
<Space orientation="vertical" size="large" style={{ width: "100%" }}> <Space orientation="vertical" size="large" style={{ width: "100%" }}>
<div> <div>
<Typography.Title level={5}>安装进度</Typography.Title> <Typography.Title level={5}>安装进度</Typography.Title>
@@ -299,25 +263,49 @@ function NodeDetailDrawer({
/> />
</div> </div>
<Card size="small" title="已配置域名"> <Card size="small" title="已同步域名">
{detail.domains.length === 0 ? ( {detail.domains.length === 0 ? (
<span className="muted">暂无</span> <span className="muted">暂无;OAuth 授权完成后自动同步</span>
) : ( ) : (
detail.domains.map((d) => ( detail.domains.map((d) => (
<div key={d.id}> <div key={d.id}>
<Tag color="blue">{d.name}</Tag> <Tag color="blue">{d.name}</Tag>
<span className="muted mono">{d.worker_id ?? d.status}</span> <span className="muted mono">
{d.worker_id ?? "未配置 Email Workers"}
</span>
</div> </div>
)) ))
)} )}
</Card> </Card>
<Card size="small" title="凭据管理"> <Card size="small" title="Ingress 与凭据">
<Space orientation="vertical" style={{ width: "100%" }}> <Space orientation="vertical" style={{ width: "100%" }} size="middle">
<Space.Compact style={{ width: "100%" }}>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
style={{ width: 180 }}
placeholder="账号名称"
/>
<Input
value={ingress}
onChange={(e) => setIngress(e.target.value)}
placeholder="Node Ingress 地址"
/>
<Button
onClick={async () => {
await updateAccount(detail.account.id, { name, ingress_url: ingress });
message.success("已保存(新邮件将从新 Ingress 转发)");
onReload();
}}
>
保存
</Button>
</Space.Compact>
<Space wrap> <Space wrap>
<Button <Button
onClick={async () => { onClick={async () => {
const res = await rotateNode(detail.node.id); const res = await rotateAccount(detail.account.id);
modal.info({ modal.info({
title: "新的 Enrollment Token(仅显示一次)", title: "新的 Enrollment Token(仅显示一次)",
content: <Typography.Paragraph copyable className="mono">{res.enrollment_token}</Typography.Paragraph>, content: <Typography.Paragraph copyable className="mono">{res.enrollment_token}</Typography.Paragraph>,
@@ -329,21 +317,22 @@ function NodeDetailDrawer({
</Button> </Button>
<Button <Button
onClick={async () => { onClick={async () => {
const res = await getNodeIngressSecret(detail.node.id); const res = await getAccountIngressSecret(detail.account.id);
setShowSecret(true); modal.info({
message.info(`Ingress Secret:${res.ingress_secret}`); title: "Ingress Secret(Email Worker -> Node 共享密钥)",
content: <Typography.Paragraph copyable className="mono">{res.ingress_secret}</Typography.Paragraph>,
});
}} }}
> >
查看 Ingress Secret 查看 Ingress Secret
</Button> </Button>
</Space> </Space>
<Typography.Text type="secondary" className="mono"> <Typography.Text type="secondary" className="mono">
enrollment 有效期至 {detail.node.enrollment_expires_at ?? "-"} enrollment 有效期至 {detail.account.enrollment_expires_at ?? "-"}
</Typography.Text> </Typography.Text>
</Space> </Space>
</Card> </Card>
</Space> </Space>
{showSecret && null}
</Drawer> </Drawer>
); );
} }
+3 -3
View File
@@ -49,12 +49,12 @@ export function DashboardPage() {
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={12} md={8} lg={4}> <Col xs={12} md={8} lg={4}>
<Card loading={loading}> <Card loading={loading}>
<Statistic title="节点在线" value={data?.nodes_online ?? 0} suffix={`/ ${data?.nodes_total ?? 0}`} /> <Statistic title="账号在线" value={data?.accounts_online ?? 0} suffix={`/ ${data?.accounts_total ?? 0}`} />
</Card> </Card>
</Col> </Col>
<Col xs={12} md={8} lg={4}> <Col xs={12} md={8} lg={4}>
<Card loading={loading}> <Card loading={loading}>
<Statistic title="域名" value={data?.domains ?? 0} /> <Statistic title="域名" value={data?.domains ?? 0} suffix={`/ ${data?.domains_active ?? 0} 已配置`} />
</Card> </Card>
</Col> </Col>
<Col xs={12} md={8} lg={4}> <Col xs={12} md={8} lg={4}>
@@ -69,7 +69,7 @@ export function DashboardPage() {
</Col> </Col>
<Col xs={12} md={8} lg={4}> <Col xs={12} md={8} lg={4}>
<Card loading={loading}> <Card loading={loading}>
<Statistic title="Webhook" value={data?.webhooks ?? 0} /> <Statistic title="全局 Webhook" value={data?.global_webhook ? "已配置" : "未配置"} />
</Card> </Card>
</Col> </Col>
<Col xs={12} md={8} lg={4}> <Col xs={12} md={8} lg={4}>
+175 -49
View File
@@ -1,8 +1,31 @@
/** 域名页 */ /**
import { useCallback, useEffect, useState } from "react"; * 域名页(v1.1.0):域名由 Cloudflare OAuth 自动同步(无手工添加);
import { Button, Card, Form, Input, Modal, Space, Table, Tag, App } from "antd"; * 点击域名配置 Email Workers(Worker + Email Routing + catch-all)与域名级 Webhook;
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons"; * 支持搜索与按账号筛选。
import { createDomain, deleteDomain, listDomains, type DomainRow } from "../api.ts"; */
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Card, Drawer, Input, Select, Space, Table, Tag, Tooltip, Typography, App } from "antd";
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import {
deleteDomain,
listDomainAccounts,
listDomains,
setupDomain,
type DomainRow,
} from "../api.ts";
import { WebhookForm } from "../components/WebhookForm.tsx";
/** # 成员变量: 域名状态 -> Tag **/
function StatusTag({ row }: { row: DomainRow }) {
if (row.status === "active") return <Tag color="success">已配置 Email Workers</Tag>;
if (row.status === "error")
return (
<Tooltip title={row.setup_error ?? "配置失败"}>
<Tag color="error">error</Tag>
</Tooltip>
);
return <Tag color="default">pending</Tag>;
}
/** # 函数 /** # 函数
* 域名页组件 * 域名页组件
@@ -10,14 +33,18 @@ import { createDomain, deleteDomain, listDomains, type DomainRow } from "../api.
export function DomainsPage() { export function DomainsPage() {
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
const [items, setItems] = useState<DomainRow[]>([]); const [items, setItems] = useState<DomainRow[]>([]);
const [accountOptions, setAccountOptions] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false); const [q, setQ] = useState("");
const [form] = Form.useForm(); const [accountId, setAccountId] = useState<string | undefined>();
const [editing, setEditing] = useState<DomainRow | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
setItems((await listDomains()).items); const [d, a] = await Promise.all([listDomains(), listDomainAccounts()]);
setItems(d.items);
setAccountOptions(a.items);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -27,44 +54,99 @@ export function DomainsPage() {
void load(); void load();
}, [load]); }, [load]);
const filtered = useMemo(
() =>
items.filter(
(row) =>
(!accountId || row.node_id === accountId) &&
row.name.toLowerCase().includes(q.trim().toLowerCase()),
),
[items, q, accountId],
);
/** 配置 Email Workers */
const runSetup = async (row: DomainRow) => {
try {
const res = await setupDomain(row.id);
if (res.ok) message.success(`已为 ${row.name} 部署 Email Worker 并开启 Email Routing`);
else message.error(res.error ?? "配置失败");
void load();
setEditing((cur) => (cur?.id === row.id ? { ...cur, status: res.ok ? "active" : "error" } : cur));
} catch (err) {
message.error(err instanceof Error ? err.message : "配置失败");
}
};
return ( return (
<div> <div>
<div className="page-toolbar"> <div className="page-toolbar">
<span className="muted">OAuth 安装流程会自动创建域名;此处也可手工登记。</span> <div className="filters">
<Space> <Input
<Button icon={<ReloadOutlined />} onClick={() => void load()} /> allowClear
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)}> prefix={<SearchOutlined />}
添加域名 placeholder="搜索域名"
</Button> style={{ width: 240 }}
</Space> onChange={(e) => setQ(e.target.value)}
/>
<Select
allowClear
placeholder="按账号筛选"
style={{ width: 200 }}
value={accountId}
onChange={setAccountId}
options={accountOptions.map((a) => ({ value: a.id, label: a.name }))}
/>
<span className="muted">域名在 Cloudflare OAuth 授权后自动同步;点击域名行设置 Email Workers 与 Webhook。</span>
</div> </div>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
</div>
<Card styles={{ body: { padding: 0 } }}> <Card styles={{ body: { padding: 0 } }}>
<Table <Table
rowKey="id" rowKey="id"
loading={loading} loading={loading}
dataSource={items} dataSource={filtered}
pagination={false} pagination={false}
onRow={(row) => ({ onClick: () => setEditing(row), style: { cursor: "pointer" } })}
columns={[ columns={[
{ title: "域名", dataIndex: "name" }, { title: "域名", dataIndex: "name", render: (v: string) => <span className="mono">{v}</span> },
{ title: "账号", dataIndex: "node_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
{ title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => <StatusTag row={row} /> },
{ {
title: "状态", title: "Email Worker",
dataIndex: "status", dataIndex: "worker_id",
width: 100, width: 210,
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>, ellipsis: true,
render: (v: string | null) => (v ? <span className="mono">{v}</span> : <span className="muted">未配置</span>),
}, },
{ title: "节点", dataIndex: "node_name", width: 160, render: (v: string | null) => v ?? "-" }, {
{ title: "Worker", dataIndex: "worker_id", width: 220, render: (v: string | null) => v ?? "-" }, title: "Webhook",
{ title: "邮箱数", dataIndex: "mailbox_count", width: 90 }, width: 95,
render: (_: unknown, row: DomainRow) =>
row.webhook_url ? <Tag color="blue">已配置</Tag> : <span className="muted">-</span>,
},
{ title: "邮箱数", dataIndex: "mailbox_count", width: 80 },
{ {
title: "操作", title: "操作",
width: 90, width: 200,
render: (_: unknown, row: DomainRow) => ( render: (_: unknown, row: DomainRow) => (
<Space onClick={(e) => e.stopPropagation()}>
{row.status !== "active" ? (
<Button size="small" type="primary" onClick={() => void runSetup(row)}>
设置 Email Workers
</Button>
) : (
<Button size="small" onClick={() => void runSetup(row)}>
重新配置
</Button>
)}
<Button <Button
size="small" size="small"
danger danger
onClick={() => onClick={() =>
modal.confirm({ modal.confirm({
title: `删除域名 ${row.name}?`, title: `删除域名 ${row.name}?`,
content: "仅在域名无邮箱时可删;删除后重新 OAuth 授权会再次同步。",
onOk: async () => { onOk: async () => {
try { try {
await deleteDomain(row.id); await deleteDomain(row.id);
@@ -79,38 +161,82 @@ export function DomainsPage() {
> >
删除 删除
</Button> </Button>
</Space>
), ),
}, },
]} ]}
/> />
</Card> </Card>
<Modal <Drawer
title="添加域名" title={
open={open} editing ? (
onCancel={() => setOpen(false)} <span>
onOk={async () => { <span className="mono">{editing.name}</span>
const values = await form.validateFields(); <Typography.Text type="secondary" style={{ marginLeft: 8, fontWeight: 400 }}>
try { {editing.node_name ?? "-"}
await createDomain(values.name); </Typography.Text>
message.success("已添加"); </span>
setOpen(false); ) : (
form.resetFields(); "域名"
void load(); )
} catch (err) {
message.error(err instanceof Error ? err.message : "添加失败");
} }
}} size="large"
okText="添加" open={Boolean(editing)}
cancelText="取消" onClose={() => setEditing(null)}
destroyOnHidden
> >
<Form form={form} layout="vertical"> {editing && (
<Form.Item name="name" label="域名" rules={[{ required: true, message: "请输入域名" }]}> <Space orientation="vertical" size="large" style={{ width: "100%" }}>
<Input placeholder="example.com" /> <Card
</Form.Item> size="small"
</Form> title="Email Workers"
</Modal> extra={
editing.status !== "active" && (
<Button size="small" type="primary" onClick={() => void runSetup(editing)}>
设置 Email Workers
</Button>
)
}
>
<Space orientation="vertical" style={{ width: "100%" }}>
<StatusTag row={editing} />
<Typography.Text type="secondary">
部署账号级 Email Worker(接收 Cloudflare 邮件事件),开启该域名的 Email Routing 并把 catch-all
规则指向 Worker;邮件经由 Node 转发回 Console。
</Typography.Text>
{editing.status === "active" && editing.worker_id && (
<Typography.Text className="mono">
Worker:{editing.worker_id} · Zone:{editing.zone_id}
</Typography.Text>
)}
{editing.status === "error" && editing.setup_error && (
<Typography.Text type="danger">{editing.setup_error}</Typography.Text>
)}
</Space>
</Card>
<Card size="small" title="域名级 Webhook">
<Typography.Text type="secondary" style={{ display: "block", marginBottom: 12 }}>
该域名下邮件未命中邮箱级 Webhook 时使用;再未配置则回退全局 Webhook(设置中配置)。
</Typography.Text>
<WebhookForm
scope="domain"
targetId={editing.id}
value={{
url: editing.webhook_url,
headers: editing.webhook_headers,
hasKey: Boolean(editing.webhook_has_key),
}}
onSaved={async () => {
const fresh = (await listDomains()).items.find((d) => d.id === editing.id) ?? null;
setEditing(fresh);
void load();
}}
/>
</Card>
</Space>
)}
</Drawer>
</div> </div>
); );
} }
-158
View File
@@ -1,158 +0,0 @@
/** 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 : "生成失败");
}
}
}
+79 -11
View File
@@ -1,8 +1,17 @@
/** 邮箱页:创建/停用/删除/保留策略 */ /** 邮箱页:创建/停用/删除/保留策略/邮箱级 Webhook;支持搜索 */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App } from "antd"; import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App } from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons"; import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { createMailbox, deleteMailbox, listDomains, listMailboxes, patchMailbox, type DomainRow, type MailboxRow } from "../api.ts"; import {
createMailbox,
deleteMailbox,
listDomains,
listMailboxes,
patchMailbox,
type DomainRow,
type MailboxRow,
} from "../api.ts";
import { WebhookForm } from "../components/WebhookForm.tsx";
/** # 函数 /** # 函数
* 邮箱页组件 * 邮箱页组件
@@ -13,6 +22,8 @@ export function MailboxesPage() {
const [domains, setDomains] = useState<DomainRow[]>([]); const [domains, setDomains] = useState<DomainRow[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [webhookTarget, setWebhookTarget] = useState<MailboxRow | null>(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -30,6 +41,14 @@ export function MailboxesPage() {
void load(); void load();
}, [load]); }, [load]);
const filtered = useMemo(
() =>
items.filter((row) =>
`${row.address} ${row.domain_name}`.toLowerCase().includes(q.trim().toLowerCase()),
),
[items, q],
);
const toggleStatus = async (row: MailboxRow) => { const toggleStatus = async (row: MailboxRow) => {
await patchMailbox(row.id, { status: row.status === "active" ? "disabled" : "active" }); await patchMailbox(row.id, { status: row.status === "active" ? "disabled" : "active" });
void load(); void load();
@@ -38,7 +57,16 @@ export function MailboxesPage() {
return ( return (
<div> <div>
<div className="page-toolbar"> <div className="page-toolbar">
<div className="filters">
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索地址 / 域名"
style={{ width: 260 }}
onChange={(e) => setQ(e.target.value)}
/>
<span className="muted">catch-all 邮箱(*@domain)会接收该域名下所有未精确匹配的邮件。</span> <span className="muted">catch-all 邮箱(*@domain)会接收该域名下所有未精确匹配的邮件。</span>
</div>
<Space> <Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} /> <Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)} disabled={domains.length === 0}> <Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)} disabled={domains.length === 0}>
@@ -50,7 +78,7 @@ export function MailboxesPage() {
<Table <Table
rowKey="id" rowKey="id"
loading={loading} loading={loading}
dataSource={items} dataSource={filtered}
pagination={false} pagination={false}
columns={[ columns={[
{ {
@@ -62,31 +90,41 @@ export function MailboxesPage() {
</span> </span>
), ),
}, },
{ title: "域名", dataIndex: "domain_name", width: 180 }, { title: "域名", dataIndex: "domain_name", width: 170, ellipsis: true },
{ {
title: "状态", title: "状态",
dataIndex: "status", dataIndex: "status",
width: 100, width: 95,
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>, render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>,
}, },
{
title: "Webhook",
width: 90,
render: (_: unknown, row: MailboxRow) =>
row.webhook_url ? <Tag color="blue">已配置</Tag> : <span className="muted">-</span>,
},
{ {
title: "保留(秒)", title: "保留(秒)",
dataIndex: "retention_seconds", dataIndex: "retention_seconds",
width: 110, width: 105,
render: (v: number | null) => v ?? <span className="muted">默认 86400</span>, render: (v: number | null) => v ?? <span className="muted">默认 86400</span>,
}, },
{ title: "邮件数", dataIndex: "email_count", width: 90 }, { title: "邮件数", dataIndex: "email_count", width: 85 },
{ {
title: "启用", title: "启用",
width: 80, width: 75,
render: (_: unknown, row: MailboxRow) => ( render: (_: unknown, row: MailboxRow) => (
<Switch checked={row.status === "active"} onChange={() => void toggleStatus(row)} /> <Switch checked={row.status === "active"} onChange={() => void toggleStatus(row)} />
), ),
}, },
{ {
title: "操作", title: "操作",
width: 90, width: 150,
render: (_: unknown, row: MailboxRow) => ( render: (_: unknown, row: MailboxRow) => (
<Space>
<Button size="small" onClick={() => setWebhookTarget(row)}>
Webhook
</Button>
<Button <Button
size="small" size="small"
danger danger
@@ -103,6 +141,7 @@ export function MailboxesPage() {
> >
删除 删除
</Button> </Button>
</Space>
), ),
}, },
]} ]}
@@ -152,6 +191,35 @@ export function MailboxesPage() {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal
title={`邮箱级 Webhook:${webhookTarget?.address ?? ""}`}
open={Boolean(webhookTarget)}
onCancel={() => setWebhookTarget(null)}
footer={null}
width={560}
destroyOnHidden
>
<p className="muted" style={{ marginTop: 0 }}>
该邮箱收到邮件时优先使用此 Webhook;未配置则回退域名级,再回退全局(设置中配置)。
</p>
{webhookTarget && (
<WebhookForm
scope="mailbox"
targetId={webhookTarget.id}
value={{
url: webhookTarget.webhook_url,
headers: webhookTarget.webhook_headers,
hasKey: Boolean(webhookTarget.webhook_has_key),
}}
onSaved={async () => {
const fresh = (await listMailboxes()).items.find((m) => m.id === webhookTarget.id) ?? null;
setWebhookTarget(fresh);
void load();
}}
/>
)}
</Modal>
</div> </div>
); );
} }
+5 -5
View File
@@ -20,14 +20,14 @@ export function OAuthSimPage() {
const res = await oauthSimApprove(state); const res = await oauthSimApprove(state);
if (res.ok) { if (res.ok) {
message.success("Cloudflare 授权与自动安装完成"); message.success("Cloudflare 授权与自动安装完成");
navigate(`/nodes?provision=${encodeURIComponent(res.node_id)}`, { replace: true }); navigate(`/accounts?provision=${encodeURIComponent(res.node_id)}`, { replace: true });
} else { } else {
message.error(res.error ?? "安装失败"); message.error(res.error ?? "安装失败");
navigate("/nodes", { replace: true }); navigate("/accounts", { replace: true });
} }
} catch (err) { } catch (err) {
message.error(err instanceof Error ? err.message : "授权失败"); message.error(err instanceof Error ? err.message : "授权失败");
navigate("/nodes", { replace: true }); navigate("/accounts", { replace: true });
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -35,7 +35,7 @@ export function OAuthSimPage() {
// state 缺失直接回节点页 // state 缺失直接回节点页
useEffect(() => { useEffect(() => {
if (!state) navigate("/nodes", { replace: true }); if (!state) navigate("/accounts", { replace: true });
}, [state, navigate]); }, [state, navigate]);
return ( return (
@@ -44,7 +44,7 @@ export function OAuthSimPage() {
<p>sim 模式下,该页面模拟 Cloudflare OAuth 授权同意流程。</p> <p>sim 模式下,该页面模拟 Cloudflare OAuth 授权同意流程。</p>
<p className="muted">真实部署时,这里会跳转到 dash.cloudflare.com 的授权页面。</p> <p className="muted">真实部署时,这里会跳转到 dash.cloudflare.com 的授权页面。</p>
<Space style={{ width: "100%", justifyContent: "center", marginTop: 16 }}> <Space style={{ width: "100%", justifyContent: "center", marginTop: 16 }}>
<Button onClick={() => navigate("/nodes")}>拒绝</Button> <Button onClick={() => navigate("/accounts")}>拒绝</Button>
<Button type="primary" loading={busy} onClick={() => void approve()}> <Button type="primary" loading={busy} onClick={() => void approve()}>
同意授权 同意授权
</Button> </Button>
-266
View File
@@ -1,266 +0,0 @@
/** 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>
);
}
+29 -3
View File
@@ -39,6 +39,20 @@ body,
letter-spacing: 0.2px; letter-spacing: 0.2px;
} }
/* 版本号 tag:字体小一号、居上对齐 */
.brand-version {
align-self: flex-start;
margin-top: 1px;
padding: 0 6px;
font-size: 10.5px;
line-height: 16px;
color: #2f54eb;
background: rgba(47, 84, 235, 0.08);
border: 1px solid rgba(47, 84, 235, 0.25);
border-radius: 8px;
white-space: nowrap;
}
.app-header { .app-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -47,15 +61,27 @@ body,
border-bottom: 1px solid rgba(5, 5, 5, 0.06); border-bottom: 1px solid rgba(5, 5, 5, 0.06);
} }
/* 标题/副标题紧凑排列:行高收紧、不换行、溢出省略 */
.header-meta {
min-width: 0;
line-height: 0;
}
.app-header .title { .app-header .title {
font-size: 20px; font-size: 17px;
font-weight: 650; font-weight: 650;
line-height: 22px;
white-space: nowrap;
} }
.app-header .subtitle { .app-header .subtitle {
font-size: 12.5px; font-size: 12px;
line-height: 16px;
color: rgba(0, 0, 0, 0.45); color: rgba(0, 0, 0, 0.45);
margin-top: 1px; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 56vw;
} }
.app-content { .app-content {
+5 -5
View File
@@ -4,9 +4,9 @@
* 路由分区: * 路由分区:
* /api/health 健康检查 * /api/health 健康检查
* /api/auth/* 用户登录/登出/会话(cookie) * /api/auth/* 用户登录/登出/会话(cookie)
* /api/nodes|domains|mailboxes|emails 管理资源 CRUD(会话保护) * /api/accounts|domains|mailboxes|emails 管理资源 CRUD(会话保护)
* /api/webhooks/* Webhook 管理(会话保护) * /api/settings/* 设置:全局 Webhook / 默认 Ingress / 改密码(会话保护)
* /api/keys/* API/MCP Key 管理(会话保护) * /api/keys/* API/MCP Key 管理(会话保护;设置模态窗口内使用)
* /api/oauth/* Cloudflare OAuth(会话保护) * /api/oauth/* Cloudflare OAuth(会话保护)
* /api/admin/* 版本/统计/Cron(会话保护) * /api/admin/* 版本/统计/Cron(会话保护)
* /api/internal/* Node 内部协议(HMAC 签名) * /api/internal/* Node 内部协议(HMAC 签名)
@@ -22,7 +22,7 @@ import { csrfOriginCheck } from "./middleware.ts";
import { authRoutes } from "./auth.ts"; import { authRoutes } from "./auth.ts";
import { internalRoutes } from "./routes/internal.ts"; import { internalRoutes } from "./routes/internal.ts";
import { resourceRoutes } from "./routes/resources.ts"; import { resourceRoutes } from "./routes/resources.ts";
import { webhookRoutes } from "./routes/webhooks.ts"; import { settingsRoutes } from "./routes/settings.ts";
import { keyRoutes } from "./routes/keys.ts"; import { keyRoutes } from "./routes/keys.ts";
import { oauthRoutes } from "./routes/oauth.ts"; import { oauthRoutes } from "./routes/oauth.ts";
import { apiV1Routes } from "./routes/apiv1.ts"; import { apiV1Routes } from "./routes/apiv1.ts";
@@ -54,7 +54,7 @@ export function createApp(
app.route("/api/auth", authRoutes()); app.route("/api/auth", authRoutes());
app.route("/api/internal", internalRoutes()); app.route("/api/internal", internalRoutes());
app.route("/api/oauth", oauthRoutes()); app.route("/api/oauth", oauthRoutes());
app.route("/api/webhooks", webhookRoutes()); app.route("/api/settings", settingsRoutes());
app.route("/api/keys", keyRoutes()); app.route("/api/keys", keyRoutes());
app.route("/api/admin", adminRoutes()); app.route("/api/admin", adminRoutes());
app.route("/api/v1/mcp", mcpRoutes()); app.route("/api/v1/mcp", mcpRoutes());
+30
View File
@@ -104,3 +104,33 @@ export async function decryptSecret(env: Env, blob: string): Promise<string> {
export function keyHash(key: string): Promise<string> { export function keyHash(key: string): Promise<string> {
return sha256Hex(key); return sha256Hex(key);
} }
/** # 函数
* OAuth 连接 token 的加密密钥(沿用历史派生方式,保持既有密文可解)
*/
async function getConnectionKey(env: Env): Promise<CryptoKey> {
return deriveAesKey((await getSetting(env, "app_secret")) ?? "wmailhub");
}
/** # 函数
* 加密 Cloudflare OAuth access token 入库
*/
export async function encryptConnectionToken(env: Env, token: string): Promise<string> {
return aesEncrypt(await getConnectionKey(env), token);
}
/** # 函数
* 读取最近一条 OAuth 连接的 access token 明文(供 Cloudflare API 调用)
* 返回值: 无连接或解密失败返回 null
*/
export async function decryptConnectionToken(env: Env): Promise<string | null> {
const row = await env.DB.prepare(
"SELECT encrypted_access_token FROM oauth_connections ORDER BY created_at DESC LIMIT 1",
).first<{ encrypted_access_token: string }>();
if (!row) return null;
try {
return await aesDecrypt(await getConnectionKey(env), row.encrypted_access_token);
} catch {
return null;
}
}
+5
View File
@@ -31,10 +31,15 @@ const db = new Database(path.join(DATA_DIR, "wmailhub.local.sqlite"), { create:
db.run("PRAGMA journal_mode = WAL;"); db.run("PRAGMA journal_mode = WAL;");
const migrationsDir = path.join(ROOT, "..", "migrations"); const migrationsDir = path.join(ROOT, "..", "migrations");
if (existsSync(migrationsDir)) { if (existsSync(migrationsDir)) {
// 已应用的迁移记录在 _migrations 表(ALTER TABLE 之类无法重复执行)
db.run("CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)");
for (const file of readdirSync(migrationsDir).sort()) { for (const file of readdirSync(migrationsDir).sort()) {
if (!file.endsWith(".sql")) continue; if (!file.endsWith(".sql")) continue;
const applied = db.query("SELECT 1 FROM _migrations WHERE name = ?").get(file);
if (applied) continue;
const sql = await Bun.file(path.join(migrationsDir, file)).text(); const sql = await Bun.file(path.join(migrationsDir, file)).text();
db.exec(sql); db.exec(sql);
db.query("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run(file, new Date().toISOString());
console.log(`[console] migration applied: ${file}`); console.log(`[console] migration applied: ${file}`);
} }
} }
+6 -3
View File
@@ -28,13 +28,16 @@ export function adminRoutes(): App {
(await c.env.DB.prepare(sql).first<{ n: number }>())?.n ?? 0; (await c.env.DB.prepare(sql).first<{ n: number }>())?.n ?? 0;
const now = nowIso(); const now = nowIso();
const stats = { const stats = {
nodes_online: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status = 'online'`), accounts_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'`), accounts_total: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'`),
domains: await count("SELECT COUNT(*) AS n FROM domains"), domains: await count("SELECT COUNT(*) AS n FROM domains"),
domains_active: await count(`SELECT COUNT(*) AS n FROM domains WHERE status = 'active'`),
mailboxes: await count("SELECT COUNT(*) AS n FROM mailboxes"), mailboxes: await count("SELECT COUNT(*) AS n FROM mailboxes"),
emails: await count(`SELECT COUNT(*) AS n FROM emails WHERE expires_at > '${now}'`), 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}'`), emails_expired: await count(`SELECT COUNT(*) AS n FROM emails WHERE expires_at <= '${now}'`),
webhooks: await count("SELECT COUNT(*) AS n FROM webhooks"), global_webhook: await count(
`SELECT COUNT(*) AS n FROM settings WHERE key = 'global_webhook' AND value != '{"url":"","headers":{}}' AND value != ''`,
),
deliveries_pending: await count( deliveries_pending: await count(
`SELECT COUNT(*) AS n FROM webhook_deliveries WHERE status IN ('pending','failed')`, `SELECT COUNT(*) AS n FROM webhook_deliveries WHERE status IN ('pending','failed')`,
), ),
-8
View File
@@ -111,13 +111,5 @@ export function apiV1Routes(): App {
return c.json({ email, attachments: atts.results }); 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; return app;
} }
+1 -12
View File
@@ -1,7 +1,7 @@
/** /**
* /api/v1/mcp :MCP over Streamable HTTP(JSON-RPC 2.0),MCP Key(wmh_)认证。 * /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 * 工具:list_mailboxes / list_emails / get_email / create_mailbox / delete_mailbox
* 权限:基于 Console credential(docs/09 Phase 9);邮件强制检查未过期。 * 权限:基于 Console credential(docs/09 Phase 9);邮件强制检查未过期。
* 实现说明:POST JSON-RPC -> application/json 响应(Streamable HTTP 允许服务器 * 实现说明:POST JSON-RPC -> application/json 响应(Streamable HTTP 允许服务器
* 返回普通 JSON);GET 返回 405(无 SSE 流)。 * 返回普通 JSON);GET 返回 405(无 SSE 流)。
@@ -72,11 +72,6 @@ const TOOLS = [
additionalProperties: false, additionalProperties: false,
}, },
}, },
{
name: "list_webhooks",
description: "列出 Webhook(不含 secret)",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
] as const; ] as const;
/** # 函数 /** # 函数
@@ -239,12 +234,6 @@ async function dispatchTool(env: Env, name: string, args: Record<string, unknown
if (!res.meta?.changes) throw new McpToolError("mailbox not found", -32002); if (!res.meta?.changes) throw new McpToolError("mailbox not found", -32002);
return { ok: true }; 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: default:
throw new McpToolError(`unknown tool: ${name}`, -32601); throw new McpToolError(`unknown tool: ${name}`, -32601);
} }
+24 -11
View File
@@ -12,10 +12,10 @@
import { Hono } from "hono"; import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts"; import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts"; import { requireSession } from "../middleware.ts";
import { aesEncrypt, deriveAesKey, ids } from "../../../shared/src/index.ts"; import { ids } from "../../../shared/src/index.ts";
import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi } from "../services/cfapi.ts"; import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi } from "../services/cfapi.ts";
import { provisionNode } from "../services/provision.ts"; import { provisionNode, createNode } from "../services/provision.ts";
import { getSetting } from "../db.ts"; import { getSetting, encryptConnectionToken } from "../db.ts";
import { nowIso, readJson } from "../util.ts"; import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>; type App = Hono<{ Bindings: Env; Variables: AppVars }>;
@@ -23,6 +23,9 @@ type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 成员变量: OAuth state 有效期(秒) **/ /** # 成员变量: OAuth state 有效期(秒) **/
const STATE_TTL_SECONDS = 300; const STATE_TTL_SECONDS = 300;
/** # 成员变量: 自动创建账号时的默认 Node Ingress 地址(可在设置中修改) **/
export const DEFAULT_INGRESS_URL = "http://127.0.0.1:18788/ingress/email";
/** # 函数 /** # 函数
* 是否使用真实 Cloudflare OAuth * 是否使用真实 Cloudflare OAuth
*/ */
@@ -45,13 +48,24 @@ export function oauthRoutes(): App {
}); });
}); });
/** 开始授权:创建 state 并返回跳转 URL */ /** 开始授权:node_id 缺省时自动创建账号;创建 state 并返回跳转 URL */
app.get("/cloudflare/start", async (c) => { app.get("/cloudflare/start", async (c) => {
const nodeId = new URL(c.req.url).searchParams.get("node_id") ?? ""; let 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 = ?") if (nodeId) {
.bind(nodeId, nodeId) const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
.bind(nodeId)
.first<{ id: string }>(); .first<{ id: string }>();
if (!node) return c.json({ error: "node not found" }, 404); if (!node) return c.json({ error: "account not found" }, 404);
} else {
// 添加账号:直接进入 OAuth,授权成功后自动落地账号(identity + 域名同步)
const ingressUrl =
(await getSetting(c.env, "default_ingress_url"))?.trim() || DEFAULT_INGRESS_URL;
const created = await createNode(c.env, {
name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`,
ingressUrl,
});
nodeId = created.nodeId;
}
const state = "cfstate_" + ids.oauthState(); const state = "cfstate_" + ids.oauthState();
await c.env.DB.prepare( await c.env.DB.prepare(
@@ -92,7 +106,7 @@ export function oauthRoutes(): App {
// 加密入库(不进前端/日志) // 加密入库(不进前端/日志)
await saveConnection(c.env, token.accessToken, token.expiresIn); await saveConnection(c.env, token.accessToken, token.expiresIn);
const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken }); const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken });
const target = `/nodes?provision=${encodeURIComponent(verdict.nodeId)}&ok=${result.ok ? 1 : 0}`; const target = `/accounts?provision=${encodeURIComponent(verdict.nodeId)}&ok=${result.ok ? 1 : 0}`;
return c.redirect(target, 302); return c.redirect(target, 302);
} catch (err) { } catch (err) {
return c.text(`OAuth provisioning failed: ${err instanceof Error ? err.message : String(err)}`, 500); return c.text(`OAuth provisioning failed: ${err instanceof Error ? err.message : String(err)}`, 500);
@@ -143,8 +157,7 @@ async function consumeState(
* OAuth access token 加密入库 * OAuth access token 加密入库
*/ */
async function saveConnection(env: Env, accessToken: string, expiresIn: number): Promise<void> { async function saveConnection(env: Env, accessToken: string, expiresIn: number): Promise<void> {
const key = await deriveAesKey(await getSetting(env, "app_secret") ?? "wmailhub"); const encrypted = await encryptConnectionToken(env, accessToken);
const encrypted = await aesEncrypt(key, accessToken);
await env.DB.prepare( await env.DB.prepare(
`INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at) `INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at)
VALUES (?, 'cloudflare', NULL, ?, NULL, ?, ?, ?)`, VALUES (?, 'cloudflare', NULL, ?, NULL, ?, ?, ?)`,
+131 -74
View File
@@ -1,28 +1,31 @@
/** /**
* Console 管理路由(Node / Domain / Mailbox / Email)。 * Console 管理路由(Account(账号) / Domain / Mailbox / Email)。
* 全部要求会话登录。 * 全部要求会话登录。v1.1.0:
* - 节点在管理面更名「账号」,路由 /api/accounts(内部 Node 协议 /api/internal/nodes 不变)
* - 删除手工添加域名;域名由 Cloudflare OAuth 自动同步
* - POST /domains/:id/setup 为域名配置 Email Workers(Worker + Routing + catch-all)
* - 邮箱/域名级 Webhook 配置(PUT/DELETE .../webhook)
*/ */
import { Hono } from "hono"; import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts"; import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts"; import { requireSession } from "../middleware.ts";
import { createNode, emailWorkerNameFor, loadSteps } from "../services/provision.ts"; import { loadSteps, setupDomainEmail, upsertMailbox } from "../services/provision.ts";
import { upsertMailbox, upsertDomain } from "../services/provision.ts";
import { decryptSecret } from "../db.ts"; import { decryptSecret } from "../db.ts";
import { ids } from "../../../shared/src/index.ts";
import { checkWebhookUrl } from "../../../shared/src/index.ts"; import { checkWebhookUrl } from "../../../shared/src/index.ts";
import { normalizeWebhookHeaders, saveScopedWebhook } from "../services/webhook_delivery.ts";
import { nowIso, pageParams, readJson } from "../util.ts"; import { nowIso, pageParams, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>; type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 函数 /** # 函数
* 装配 Node / Domain / Mailbox / Email 管理路由 * 装配 Account / Domain / Mailbox / Email 管理路由
*/ */
export function resourceRoutes(): App { export function resourceRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession); app.use("*", requireSession);
installNodeRoutes(app); installAccountRoutes(app);
installDomainRoutes(app); installDomainRoutes(app);
installMailboxRoutes(app); installMailboxRoutes(app);
installEmailRoutes(app); installEmailRoutes(app);
@@ -30,11 +33,34 @@ export function resourceRoutes(): App {
} }
/** # 函数 /** # 函数
* Node CRUD 与安装状态 * 解析并校验 webhook 配置请求体
* 返回值: { error } 或规整后的 { url, headers, key }(key 为 undefined 表示保留现有 key)
*/ */
function installNodeRoutes(app: App) { async function parseWebhookBody(
/** Node 列表(不含任何 secret) */ c: { env: Env; req: { raw: Request } },
app.get("/nodes", async (c) => { ): Promise<{ error: string } | { url: string; headers: Record<string, string>; key: string | undefined }> {
const body = await readJson<{ url?: string; headers?: unknown; key?: string }>(c.req.raw);
const url = (body.url ?? "").trim();
let headers: Record<string, string>;
try {
headers = normalizeWebhookHeaders(body.headers);
} catch (err) {
return { error: err instanceof Error ? err.message : "invalid headers" };
}
if (url) {
const unsafe = checkWebhookUrl(url, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
if (unsafe) return { error: unsafe };
}
const rawKey = typeof body.key === "string" ? body.key : undefined;
return { url, headers, key: rawKey !== undefined && rawKey.trim() === "" ? "" : rawKey };
}
/** # 函数
* Account(账号)管理与安装状态
*/
function installAccountRoutes(app: App) {
/** 账号列表(不含任何 secret) */
app.get("/accounts", async (c) => {
const { limit, offset } = pageParams(new URL(c.req.url)); const { limit, offset } = pageParams(new URL(c.req.url));
const rows = await c.env.DB.prepare( const rows = await c.env.DB.prepare(
`SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at, `SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at,
@@ -47,38 +73,17 @@ function installNodeRoutes(app: App) {
return c.json({ items: rows.results, total: total?.n ?? 0 }); return c.json({ items: rows.results, total: total?.n ?? 0 });
}); });
/** Add Node:创建节点,返回 enrollment token(仅此一次展示)与 OAuth 授权入口信息 */ /** 账号详情:含安装步骤状态与 OAuth 连接状态 */
app.post("/nodes", async (c) => { app.get("/accounts/:id", 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 id = c.req.param("id");
const node = await c.env.DB.prepare( const account = await c.env.DB.prepare(
`SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at, `SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at,
ingress_url, enrollment_expires_at, created_at, updated_at ingress_url, enrollment_expires_at, created_at, updated_at
FROM nodes WHERE id = ?`, FROM nodes WHERE id = ?`,
) )
.bind(id) .bind(id)
.first(); .first();
if (!node) return c.json({ error: "not found" }, 404); if (!account) return c.json({ error: "not found" }, 404);
const steps = await loadSteps(c.env, id); const steps = await loadSteps(c.env, id);
const oauth = await c.env.DB.prepare( 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", "SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC LIMIT 1",
@@ -86,11 +91,38 @@ function installNodeRoutes(app: App) {
const domains = await c.env.DB.prepare("SELECT id, name, status, worker_id FROM domains WHERE node_id = ?") const domains = await c.env.DB.prepare("SELECT id, name, status, worker_id FROM domains WHERE node_id = ?")
.bind(id) .bind(id)
.all(); .all();
return c.json({ node, steps, oauth: oauth ?? null, domains: domains.results }); return c.json({ account, steps, oauth: oauth ?? null, domains: domains.results });
}); });
/** 删除 Node(软删:撤销 credential + 标记 revoked) */ /** 更新账号:名称 / Node Ingress 地址(Email Worker 回源地址) */
app.delete("/nodes/:id", async (c) => { app.patch("/accounts/:id", async (c) => {
const id = c.req.param("id");
const body = await readJson<{ name?: string; ingress_url?: string }>(c.req.raw);
const account = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
.bind(id)
.first();
if (!account) return c.json({ error: "not found" }, 404);
if (body.name !== undefined) {
const name = body.name.trim();
if (!name) return c.json({ error: "name required" }, 400);
await c.env.DB.prepare("UPDATE nodes SET name = ?, updated_at = ? WHERE id = ?").bind(name, nowIso(), id).run();
}
if (body.ingress_url !== undefined) {
const ingressUrl = body.ingress_url.trim();
try {
new URL(ingressUrl);
} catch {
return c.json({ error: "invalid ingress_url" }, 400);
}
await c.env.DB.prepare("UPDATE nodes SET ingress_url = ?, updated_at = ? WHERE id = ?")
.bind(ingressUrl, nowIso(), id)
.run();
}
return c.json({ ok: true });
});
/** 删除账号(软删:撤销 credential + 标记 revoked) */
app.delete("/accounts/:id", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
await c.env.DB.prepare( await c.env.DB.prepare(
"UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'", "UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'",
@@ -104,12 +136,12 @@ function installNodeRoutes(app: App) {
}); });
/** 重新生成 enrollment token(credential 轮换入口) */ /** 重新生成 enrollment token(credential 轮换入口) */
app.post("/nodes/:id/rotate", async (c) => { app.post("/accounts/:id/rotate", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'") const account = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
.bind(id) .bind(id)
.first(); .first();
if (!node) return c.json({ error: "not found" }, 404); if (!account) return c.json({ error: "not found" }, 404);
const token = "wenr_" + crypto.randomUUID().replace(/-/g, ""); const token = "wenr_" + crypto.randomUUID().replace(/-/g, "");
await c.env.DB.prepare( await c.env.DB.prepare(
"UPDATE nodes SET enrollment_token = ?, enrollment_expires_at = ?, updated_at = ? WHERE id = ?", "UPDATE nodes SET enrollment_token = ?, enrollment_expires_at = ?, updated_at = ? WHERE id = ?",
@@ -120,24 +152,26 @@ function installNodeRoutes(app: App) {
}); });
/** 本地/运维用途:查看 ingress secret(会话保护;真实部署由 provisioning 自动注入 Worker) */ /** 本地/运维用途:查看 ingress secret(会话保护;真实部署由 provisioning 自动注入 Worker) */
app.get("/nodes/:id/ingress-secret", async (c) => { app.get("/accounts/:id/ingress-secret", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
const node = await c.env.DB.prepare("SELECT ingress_secret_enc FROM nodes WHERE id = ?") const account = await c.env.DB.prepare("SELECT ingress_secret_enc FROM nodes WHERE id = ?")
.bind(id) .bind(id)
.first<{ ingress_secret_enc: string }>(); .first<{ ingress_secret_enc: string }>();
if (!node) return c.json({ error: "not found" }, 404); if (!account) return c.json({ error: "not found" }, 404);
return c.json({ ingress_secret: await decryptSecret(c.env, node.ingress_secret_enc) }); return c.json({ ingress_secret: await decryptSecret(c.env, account.ingress_secret_enc) });
}); });
} }
/** # 函数 /** # 函数
* Domain CRUD * Domain 查询 / Email Workers 配置 / Webhook 配置
*/ */
function installDomainRoutes(app: App) { function installDomainRoutes(app: App) {
app.get("/domains", async (c) => { app.get("/domains", async (c) => {
const { limit, offset } = pageParams(new URL(c.req.url)); const { limit, offset } = pageParams(new URL(c.req.url));
const rows = await c.env.DB.prepare( const rows = await c.env.DB.prepare(
`SELECT d.*, n.name AS node_name, `SELECT d.id, d.name, d.status, d.setup_error, d.worker_id, d.zone_id, d.node_id,
d.webhook_url, d.webhook_headers, (d.webhook_key_enc IS NOT NULL) AS webhook_has_key,
n.name AS node_name,
(SELECT COUNT(*) FROM mailboxes m WHERE m.domain_id = d.id) AS mailbox_count (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 FROM domains d LEFT JOIN nodes n ON n.id = d.node_id
ORDER BY d.created_at DESC LIMIT ? OFFSET ?`, ORDER BY d.created_at DESC LIMIT ? OFFSET ?`,
@@ -148,19 +182,35 @@ function installDomainRoutes(app: App) {
return c.json({ items: rows.results, total: total?.n ?? 0 }); return c.json({ items: rows.results, total: total?.n ?? 0 });
}); });
/** 手工添加 Domain(供无 OAuth 场景;OAuth 安装自动创建) */ /** 域名可用的账号(供筛选) */
app.post("/domains", async (c) => { app.get("/domains/filters/accounts", async (c) => {
const body = await readJson<{ name?: string; node_id?: string; cloudflare_account_id?: string }>(c.req.raw); const rows = await c.env.DB.prepare(
const name = (body.name ?? "").trim().toLowerCase(); "SELECT DISTINCT n.id, n.name FROM nodes n JOIN domains d ON d.node_id = n.id ORDER BY n.name",
if (!name || !name.includes(".")) return c.json({ error: "invalid domain name" }, 400); ).all();
const id = await upsertDomain(c.env, { return c.json({ items: rows.results });
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);
/** 为域名配置 Email Workers(部署 Worker + Email Routing + catch-all) */
app.post("/domains/:id/setup", async (c) => {
const result = await setupDomainEmail(c.env, c.req.param("id"));
return c.json(result, result.ok ? 200 : 400);
});
/** 保存域名级 Webhook(url 为空表示清除) */
app.put("/domains/:id/webhook", async (c) => {
const id = c.req.param("id");
const domain = await c.env.DB.prepare("SELECT id FROM domains WHERE id = ?").bind(id).first();
if (!domain) return c.json({ error: "not found" }, 404);
const parsed = await parseWebhookBody(c);
if ("error" in parsed) return c.json({ error: parsed.error }, 400);
await saveScopedWebhook(c.env, "domains", id, parsed);
return c.json({ ok: true });
});
/** 清除域名级 Webhook */
app.delete("/domains/:id/webhook", async (c) => {
await saveScopedWebhook(c.env, "domains", c.req.param("id"), { url: "" });
return c.json({ ok: true });
}); });
app.delete("/domains/:id", async (c) => { app.delete("/domains/:id", async (c) => {
@@ -175,7 +225,7 @@ function installDomainRoutes(app: App) {
} }
/** # 函数 /** # 函数
* Mailbox CRUD * Mailbox CRUD 与邮箱级 Webhook 配置
*/ */
function installMailboxRoutes(app: App) { function installMailboxRoutes(app: App) {
app.get("/mailboxes", async (c) => { app.get("/mailboxes", async (c) => {
@@ -183,7 +233,9 @@ function installMailboxRoutes(app: App) {
const domainId = new URL(c.req.url).searchParams.get("domain_id"); const domainId = new URL(c.req.url).searchParams.get("domain_id");
const where = domainId ? "WHERE m.domain_id = ?" : ""; const where = domainId ? "WHERE m.domain_id = ?" : "";
const rows = await c.env.DB.prepare( const rows = await c.env.DB.prepare(
`SELECT m.*, d.name AS domain_name, `SELECT m.id, m.address, m.status, m.is_catch_all, m.retention_seconds, m.domain_id,
m.webhook_url, m.webhook_headers, (m.webhook_key_enc IS NOT NULL) AS webhook_has_key,
d.name AS domain_name,
(SELECT COUNT(*) FROM emails e WHERE e.mailbox_id = m.id) AS email_count (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 FROM mailboxes m JOIN domains d ON d.id = m.domain_id
${where} ORDER BY m.created_at DESC LIMIT ? OFFSET ?`, ${where} ORDER BY m.created_at DESC LIMIT ? OFFSET ?`,
@@ -239,6 +291,23 @@ function installMailboxRoutes(app: App) {
return c.json({ ok: true }); return c.json({ ok: true });
}); });
/** 保存邮箱级 Webhook(url 为空表示清除) */
app.put("/mailboxes/:id/webhook", async (c) => {
const id = c.req.param("id");
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);
const parsed = await parseWebhookBody(c);
if ("error" in parsed) return c.json({ error: parsed.error }, 400);
await saveScopedWebhook(c.env, "mailboxes", id, parsed);
return c.json({ ok: true });
});
/** 清除邮箱级 Webhook */
app.delete("/mailboxes/:id/webhook", async (c) => {
await saveScopedWebhook(c.env, "mailboxes", c.req.param("id"), { url: "" });
return c.json({ ok: true });
});
app.delete("/mailboxes/:id", async (c) => { app.delete("/mailboxes/:id", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run(); await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
@@ -348,15 +417,3 @@ function installEmailRoutes(app: App) {
return c.json({ ok: true }); 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);
}
+123
View File
@@ -0,0 +1,123 @@
/**
* 设置路由(v1.1.0):
* GET /api/settings 读取:全局 Webhook / 默认 Node Ingress 地址 / 当前用户
* PUT /api/settings 保存:全局 Webhook + 默认 Node Ingress 地址
* PUT /api/settings/password 修改登录密码
* API/MCP Key 管理沿用 /api/keys(前端设置模态窗口内调用)。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { checkWebhookUrl, hashPassword, verifyPassword } from "../../../shared/src/index.ts";
import { getSetting, setSetting } from "../db.ts";
import {
getGlobalWebhookKey,
normalizeWebhookHeaders,
saveGlobalWebhook,
} from "../services/webhook_delivery.ts";
import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 函数
* 装配设置路由(会话保护)
*/
export function settingsRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession);
/** 读取设置(key 只回 last4,不回明文) */
app.get("/", async (c) => {
const userId = c.get("userId") ?? "";
const user = await c.env.DB.prepare("SELECT username FROM users WHERE id = ?").bind(userId).first<{ username: string }>();
const raw = await getSetting(c.env, "global_webhook");
let webhook: Record<string, unknown> = { url: "", headers: {}, has_key: false, key_last4: "" };
if (raw) {
try {
const cfg = JSON.parse(raw) as { url?: string; headers?: Record<string, string> };
const key = await getGlobalWebhookKey(c.env);
webhook = {
url: cfg.url ?? "",
headers: cfg.headers ?? {},
has_key: Boolean(key),
key_last4: key.slice(-4),
};
} catch {
// settings 损坏时按未配置处理
}
}
return c.json({
username: user?.username ?? "",
default_ingress_url: (await getSetting(c.env, "default_ingress_url")) ?? "",
webhook,
});
});
/** 保存设置:全局 Webhook + 默认 Node Ingress 地址 */
app.put("/", async (c) => {
const body = await readJson<{
default_ingress_url?: string;
webhook?: { url?: string; headers?: unknown; key?: string };
}>(c.req.raw);
if (body.default_ingress_url !== undefined) {
const ingressUrl = body.default_ingress_url.trim();
if (ingressUrl) {
try {
new URL(ingressUrl);
} catch {
return c.json({ error: "invalid default_ingress_url" }, 400);
}
await setSetting(c.env, "default_ingress_url", ingressUrl);
} else {
await c.env.DB.prepare("DELETE FROM settings WHERE key = 'default_ingress_url'").run();
}
}
if (body.webhook) {
const url = (body.webhook.url ?? "").trim();
let headers: Record<string, string>;
try {
headers = normalizeWebhookHeaders(body.webhook.headers);
} catch (err) {
return c.json({ error: err instanceof Error ? err.message : "invalid headers" }, 400);
}
if (url) {
const unsafe = checkWebhookUrl(url, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
if (unsafe) return c.json({ error: unsafe }, 400);
}
// key:undefined 保留现有;空白串(含 "")清除;非空设置新 key
const rawKey = typeof body.webhook.key === "string" ? body.webhook.key : undefined;
await saveGlobalWebhook(c.env, {
url,
headers,
key: rawKey !== undefined && rawKey.trim() === "" ? "" : rawKey,
});
}
return c.json({ ok: true });
});
/** 修改登录密码 */
app.put("/password", async (c) => {
const body = await readJson<{ old_password?: string; new_password?: string }>(c.req.raw);
const oldPassword = body.old_password ?? "";
const newPassword = body.new_password ?? "";
if (newPassword.length < 6) return c.json({ error: "新密码至少 6 位" }, 400);
const userId = c.get("userId") ?? "";
const user = await c.env.DB.prepare("SELECT id, password_hash FROM users WHERE id = ?")
.bind(userId)
.first<{ id: string; password_hash: string }>();
if (!user) return c.json({ error: "user not found" }, 404);
if (!(await verifyPassword(oldPassword, user.password_hash))) {
return c.json({ error: "原密码不正确" }, 400);
}
await c.env.DB.prepare("UPDATE users SET password_hash = ? WHERE id = ?")
.bind(await hashPassword(newPassword), userId)
.run();
return c.json({ ok: true });
});
return app;
}
-161
View File
@@ -1,161 +0,0 @@
/**
* 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;
}
+16 -8
View File
@@ -118,8 +118,18 @@ export class RealCFApi implements CFApi {
return this.api<CFAccount[]>(token, "/accounts"); return this.api<CFAccount[]>(token, "/accounts");
} }
listZones(token: string, accountId: string) { async listZones(token: string, accountId: string) {
return this.api<CFZone[]>(token, `/zones?account.id=${encodeURIComponent(accountId)}&per_page=50`); // 分页拉全账号下的 Zone(每页 50,最多 20 页)
const out: CFZone[] = [];
for (let page = 1; page <= 20; page++) {
const rows = await this.api<CFZone[]>(
token,
`/zones?account.id=${encodeURIComponent(accountId)}&per_page=50&page=${page}`,
);
out.push(...rows);
if (rows.length < 50) break;
}
return out;
} }
async putEmailWorker(opts: { async putEmailWorker(opts: {
@@ -199,13 +209,11 @@ export class SimCFApi implements CFApi {
} }
async listZones(_token: string, accountId: string): Promise<CFZone[]> { async listZones(_token: string, accountId: string): Promise<CFZone[]> {
// sim 返回多个演示域名,覆盖「OAuth 后自动同步全部域名」的展示
const zones = [ const zones = [
{ { id: "sim-zone-example.test", name: "example.test", account_id: accountId, status: "active" },
id: "sim-zone-example.test", { id: "sim-zone-demo.test", name: "demo.test", account_id: accountId, status: "active" },
name: "example.test", { id: "sim-zone-mailbox.dev", name: "mailbox.dev", account_id: accountId, status: "active" },
account_id: accountId,
status: "active",
},
]; ];
for (const z of zones) await this.record("zone", z.id, z); for (const z of zones) await this.record("zone", z.id, z);
return zones; return zones;
+3 -43
View File
@@ -18,6 +18,7 @@ import {
MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_BYTES,
} from "../../../shared/src/index.ts"; } from "../../../shared/src/index.ts";
import { nowIso } from "../util.ts"; import { nowIso } from "../util.ts";
import { enqueueDeliveryForEmail } from "./webhook_delivery.ts";
/** # 成员变量: postal-mime 解析出的结构(仅本文件用到的字段) **/ /** # 成员变量: postal-mime 解析出的结构(仅本文件用到的字段) **/
interface ParsedMail { interface ParsedMail {
@@ -191,15 +192,8 @@ export async function ingestEmail(env: Env, input: IngestInput): Promise<IngestR
.run(); .run();
} }
// Webhook delivery(邮件保存成功后创建;投递失败不影响入库) // Webhook 投递(按 邮箱->域名->全局 解析;未配置则不入队;投递失败不影响入库)
await createDeliveriesForEmail(env, emailId, { await enqueueDeliveryForEmail(env, emailId, mailboxId);
mailboxAddress: await mailboxAddress(env, mailboxId),
from: fromAddress,
to: toAddresses,
subject: parsed?.subject ?? "(unparsable mail)",
text: parsed?.text ?? null,
receivedAt,
});
return { return {
id: emailId, id: emailId,
@@ -245,37 +239,3 @@ export async function resolveMailbox(env: Env, addresses: string[]): Promise<str
} }
return null; 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();
}
}
+92 -69
View File
@@ -1,28 +1,26 @@
/** /**
* Node 自动安装管线(docs/02 流程): * 账号(Node)自动安装管线(v1.1.0):
* Add Node(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 -> * 添加账号(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 ->
* Account/Zone 发现 -> 创建/更新 Email Worker -> 配置 Email Routing -> * Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱。
* 创建 Domain/Mailbox -> Node Ready。
* *
* 所有步骤幂等,重复执行不产生重复资源;每步状态写入 nodes.provision_steps, * Email Workers(Worker 部署 + Email Routing + catch-all 规则)不随 OAuth 自动执行,
* 供前端安装进度展示。全程不要求用户复制安装命令(docs/10 Phase 4)。 * 而是在域名列表中按域名单独触发(setupDomainEmail),一个账号只部署一个 Email Worker。
*
* 所有步骤幂等,重复执行不产生重复资源;账号级步骤状态写入 nodes.provision_steps。
*/ */
import type { Env } from "../env.ts"; import type { Env } from "../env.ts";
import { getCFApi, type CFApi } from "./cfapi.ts"; import { getCFApi, type CFApi } from "./cfapi.ts";
import { encryptSecret, decryptSecret } from "../db.ts"; import { encryptSecret, decryptSecret, decryptConnectionToken } from "../db.ts";
import { ids, sha256Hex, DEFAULT_RETENTION_SECONDS } from "../../../shared/src/index.ts"; import { ids, sha256Hex, DEFAULT_RETENTION_SECONDS } from "../../../shared/src/index.ts";
import { nowIso } from "../util.ts"; import { nowIso } from "../util.ts";
/** # 成员变量: 安装步骤定义(顺序即展示顺序) **/ /** # 成员变量: 账号安装步骤定义(顺序即展示顺序) **/
export const PROVISION_STEPS = [ export const PROVISION_STEPS = [
{ key: "oauth", label: "Cloudflare OAuth 授权" }, { key: "oauth", label: "Cloudflare OAuth 授权" },
{ key: "identity", label: "Node identity 与 credential" }, { key: "identity", label: "Node identity 与 credential" },
{ key: "discover", label: "Account / Zone 发现" }, { key: "discover", label: "Account / Zone 发现" },
{ key: "email_worker", label: "Email Worker 创建/更新" }, { key: "domains", label: "域名与 catch-all 邮箱同步" },
{ key: "email_routing", label: "Email Routing 配置" },
{ key: "domains", label: "Domain 创建" },
{ key: "mailboxes", label: "Mailbox 创建" },
] as const; ] as const;
/** # 成员变量: 步骤状态 **/ /** # 成员变量: 步骤状态 **/
@@ -38,14 +36,14 @@ export function initialSteps(): Record<string, { status: StepStatus; error?: str
} }
/** # 函数 /** # 函数
* Node 的 Email Worker 脚本名(幂等:同一 Node 固定同名) * 账号的 Email Worker 脚本名(幂等:同一账号固定同名)
*/ */
export function emailWorkerNameFor(nodeId: string): string { export function emailWorkerNameFor(nodeId: string): string {
return `wmailhub-email-${nodeId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`; return `wmailhub-email-${nodeId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
} }
/** # 函数 /** # 函数
* Add Node:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。 * 添加账号:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。
* 返回 enrollment token 供 Node bootstrap(本地由 dev 脚本自动完成,生产在 UI 展示一次)。 * 返回 enrollment token 供 Node bootstrap(本地由 dev 脚本自动完成,生产在 UI 展示一次)。
*/ */
export async function createNode( export async function createNode(
@@ -70,7 +68,7 @@ export async function createNode(
nodeId, nodeId,
opts.name, opts.name,
opts.ingressUrl, opts.ingressUrl,
await encryptText(env, ingressSecret), await encryptSecret(env, ingressSecret),
enrollmentToken, enrollmentToken,
new Date(Date.now() + 24 * 3600 * 1000).toISOString(), new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
JSON.stringify(initialSteps()), JSON.stringify(initialSteps()),
@@ -117,7 +115,7 @@ export async function enrollNode(
.bind(credentialId, node.id, await sha256Hex(secret), await encryptSecret(env, secret), nowIso()) .bind(credentialId, node.id, await sha256Hex(secret), await encryptSecret(env, secret), nowIso())
.run(); .run();
await env.DB.prepare( await env.DB.prepare(
"UPDATE nodes SET credential_id = ?, status = CASE WHEN status = 'pending_oauth' THEN status ELSE status END, updated_at = ? WHERE id = ?", "UPDATE nodes SET credential_id = ?, updated_at = ? WHERE id = ?",
) )
.bind(credentialId, nowIso(), node.id) .bind(credentialId, nowIso(), node.id)
.run(); .run();
@@ -126,19 +124,11 @@ export async function enrollNode(
node_id: node.id, node_id: node.id,
credential_id: credentialId, credential_id: credentialId,
credential_secret: secret, credential_secret: secret,
ingress_secret: await decryptText(env, node.ingress_secret_enc), ingress_secret: await decryptSecret(env, node.ingress_secret_enc),
console_url: null, 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 { export interface ProvisionInput {
nodeId: string; nodeId: string;
@@ -147,17 +137,15 @@ export interface ProvisionInput {
} }
/** # 函数 /** # 函数
* OAuth 授权成功后的自动安装主流程(幂等,可重复调用)。 * OAuth 授权成功后的自动安装主流程:发现账号 + 同步全部域名(pending)+ catch-all 邮箱。
* 返回是否全部成功;失败时步骤状态里带 error。 * 幂等,可重复调用;返回是否全部成功;失败时步骤状态里带 error。
*/ */
export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> { export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
const cf: CFApi = getCFApi(env, env.DB); const cf: CFApi = getCFApi(env, env.DB);
const node = await env.DB.prepare( const node = await env.DB.prepare("SELECT id, name FROM nodes WHERE id = ?")
"SELECT id, name, ingress_url, ingress_secret_enc FROM nodes WHERE id = ?",
)
.bind(input.nodeId) .bind(input.nodeId)
.first<{ id: string; name: string; ingress_url: string; ingress_secret_enc: string }>(); .first<{ id: string; name: string }>();
if (!node) return { ok: false, error: "node not found" }; if (!node) return { ok: false, error: "account not found" };
const steps = await loadSteps(env, input.nodeId); const steps = await loadSteps(env, input.nodeId);
const mark = async (key: string, status: StepStatus, error?: string) => { const mark = async (key: string, status: StepStatus, error?: string) => {
@@ -175,7 +163,7 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
// 1. OAuth 授权 // 1. OAuth 授权
await mark("oauth", "done"); await mark("oauth", "done");
// 2. Node identity/credential(在 Add Node 与 enrollment 时完成) // 2. Node identity/credential(在添加账号与 enrollment 时完成)
await mark("identity", "done"); await mark("identity", "done");
// 3. Account/Zone 发现 // 3. Account/Zone 发现
@@ -185,47 +173,25 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
const account = input.accountId const account = input.accountId
? accounts.find((a) => a.id === input.accountId) ?? accounts[0] ? accounts.find((a) => a.id === input.accountId) ?? accounts[0]
: accounts[0]; : accounts[0];
await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, updated_at = ? WHERE id = ?") // 自动创建的账号以 Cloudflare 账户名命名
.bind(account.id, nowIso(), input.nodeId) const newName = node.name.startsWith("Cloudflare 账号") ? account.name : node.name;
await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?")
.bind(account.id, newName, nowIso(), input.nodeId)
.run(); .run();
const zones = await cf.listZones(input.accessToken, account.id); const zones = await cf.listZones(input.accessToken, account.id);
await mark("discover", "done"); await mark("discover", "done");
// 4. Email Worker(幂等上传) // 4. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱
await mark("email_worker", "running"); await mark("domains", "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) { 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, { await upsertDomain(env, {
cloudflareAccountId: account.id, cloudflareAccountId: account.id,
nodeId: input.nodeId, nodeId: input.nodeId,
name: zone.name, name: zone.name,
zoneId: zone.id, zoneId: zone.id,
workerId: workerName, workerId: null,
}); });
} }
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 = ?") const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE node_id = ?")
.bind(input.nodeId) .bind(input.nodeId)
.all<{ id: string; name: string }>(); .all<{ id: string; name: string }>();
@@ -236,7 +202,7 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
isCatchAll: true, isCatchAll: true,
}); });
} }
await mark("mailboxes", "done"); await mark("domains", "done");
await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?") await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?")
.bind(nowIso(), input.nodeId) .bind(nowIso(), input.nodeId)
@@ -258,6 +224,56 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{
} }
} }
/** # 函数
* 为单个域名配置 Email Workers:部署账号级 Email Worker -> 开启 Email Routing ->
* catch-all 规则指向 Worker -> 域名置 active。幂等,失败信息写入 domains.setup_error。
*/
export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok: boolean; error?: string }> {
const domain = await env.DB.prepare(
"SELECT id, name, zone_id, node_id FROM domains WHERE id = ?",
)
.bind(domainId)
.first<{ id: string; name: string; zone_id: string | null; node_id: string | null }>();
if (!domain) return { ok: false, error: "domain not found" };
if (!domain.node_id || !domain.zone_id) return { ok: false, error: "domain not bound to a Cloudflare zone" };
const node = await env.DB.prepare(
"SELECT id, cloudflare_account_id, ingress_url, ingress_secret_enc FROM nodes WHERE id = ? AND status != 'revoked'",
)
.bind(domain.node_id)
.first<{ id: string; cloudflare_account_id: string | null; ingress_url: string; ingress_secret_enc: string }>();
if (!node?.cloudflare_account_id) return { ok: false, error: "account not connected via OAuth" };
const accessToken = await decryptConnectionToken(env);
if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" };
const cf: CFApi = getCFApi(env, env.DB);
try {
const workerName = emailWorkerNameFor(node.id);
await cf.putEmailWorker({
accessToken,
accountId: node.cloudflare_account_id,
scriptName: workerName,
ingressUrl: node.ingress_url,
ingressSecret: await decryptSecret(env, node.ingress_secret_enc),
});
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });
await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName });
await env.DB.prepare(
"UPDATE domains SET worker_id = ?, status = 'active', setup_error = NULL, updated_at = ? WHERE id = ?",
)
.bind(workerName, nowIso(), domainId)
.run();
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await env.DB.prepare("UPDATE domains SET status = 'error', setup_error = ?, updated_at = ? WHERE id = ?")
.bind(message, nowIso(), domainId)
.run();
return { ok: false, error: message };
}
}
/** # 函数 /** # 函数
* 读取并解析 node 的步骤状态 * 读取并解析 node 的步骤状态
*/ */
@@ -274,7 +290,7 @@ export async function loadSteps(env: Env, nodeId: string) {
} }
/** # 函数 /** # 函数
* upsert Domain(按 account+name 幂等) * upsert Domain(按 account+name 幂等)。无 workerId 时保持/置为 pending(待配置 Email Workers)。
*/ */
export async function upsertDomain( export async function upsertDomain(
env: Env, env: Env,
@@ -283,28 +299,35 @@ export async function upsertDomain(
nodeId: string; nodeId: string;
name: string; name: string;
zoneId: string; zoneId: string;
workerId: string; workerId: string | null;
}, },
): Promise<string> { ): Promise<string> {
const existing = await env.DB.prepare( const existing = await env.DB.prepare(
"SELECT id FROM domains WHERE cloudflare_account_id = ? AND name = ?", "SELECT id, worker_id FROM domains WHERE cloudflare_account_id = ? AND name = ?",
) )
.bind(d.cloudflareAccountId, d.name) .bind(d.cloudflareAccountId, d.name)
.first<{ id: string }>(); .first<{ id: string; worker_id: string | null }>();
const now = nowIso(); const now = nowIso();
if (existing) { if (existing) {
// 已配置过 Worker 的域名不回退状态;仅当新配置带 worker 时更新
if (existing.worker_id || !d.workerId) {
await env.DB.prepare("UPDATE domains SET node_id = ?, zone_id = ?, updated_at = ? WHERE id = ?")
.bind(d.nodeId, d.zoneId, now, existing.id)
.run();
} else {
await env.DB.prepare( await env.DB.prepare(
"UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?", "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) .bind(d.nodeId, d.zoneId, d.workerId, now, existing.id)
.run(); .run();
}
return existing.id; return existing.id;
} }
const id = ids.domain(); const id = ids.domain();
await env.DB.prepare( 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', ?, ?, ?, ?)", "INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
) )
.bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId, d.zoneId, now, now) .bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId ? "active" : "pending", d.workerId, d.zoneId, now, now)
.run(); .run();
return id; return id;
} }
+260 -34
View File
@@ -1,24 +1,208 @@
/** /**
* Webhook 投递与重试(docs/06、docs/07): * Webhook 投递(docs/06、docs/07,v1.1.0 三级模型):
* - 配置解析优先级:邮箱 -> 域名 -> 全局(settings),命中即止
* - 每级配置:{ url, headers(自定义请求头 JSON), key }
* key 默认以 `Authorization: Bearer <key>` 携带(headers 里显式给了 Authorization 则不覆盖),
* 并用于投递签名:signature = HMAC-SHA256(key, `${timestamp}.${rawBody}`)
* - payload 按文档 JSON 结构(event=email.received) * - payload 按文档 JSON 结构(event=email.received)
* - 签名头:X-WmailHub-Event / Delivery / Timestamp / Signature * - 2xx 成功;失败重试(指数退避);投递行在入队时固化目标配置,重试不跟随后续配置变更
* signature = HMAC-SHA256(webhook_secret, `${timestamp}.${rawBody}`)
* - 2xx 成功;5xx/超时重试(指数退避);4xx 记失败并按策略重试到上限
* - endpoint 每次投递前重过 SSRF 校验(静态 + DNS 解析,本地运行时) * - endpoint 每次投递前重过 SSRF 校验(静态 + DNS 解析,本地运行时)
*/ */
import type { Env } from "../env.ts"; import type { Env } from "../env.ts";
import { import {
hmacSha256Hex, hmacSha256Hex,
aesEncrypt,
aesDecrypt,
assertWebhookUrlSafe, assertWebhookUrlSafe,
ids,
type HostResolver, type HostResolver,
WEBHOOK_HEADERS, WEBHOOK_HEADERS,
WEBHOOK_TIMEOUT_MS, WEBHOOK_TIMEOUT_MS,
WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_ATTEMPTS,
WEBHOOK_RETRY_BASE_MS, WEBHOOK_RETRY_BASE_MS,
} from "../../../shared/src/index.ts"; } from "../../../shared/src/index.ts";
import { encryptSecret, decryptSecret, getAesKey, getSetting, setSetting } from "../db.ts";
import { nowIso } from "../util.ts"; import { nowIso } from "../util.ts";
/** # 成员变量: 单级 Webhook 配置(解析结果) **/
export interface WebhookConfig {
/** 配置来源层级 **/
targetType: "mailbox" | "domain" | "global";
/** 目标 URL **/
url: string;
/** 自定义请求头 **/
headers: Record<string, string>;
/** key 明文(签名与 Authorization;可能为空串) **/
key: string;
}
/** # 成员变量: settings 表中全局 Webhook 的存储键 **/
export const GLOBAL_WEBHOOK_SETTING = "global_webhook";
/** # 函数
* 校验并规整自定义请求头:键名限 [A-Za-z0-9-],值转字符串;非法抛错
* 参数: headers - 前端传入的对象
* 返回值: 规整后的头(空对象返回 {})
*/
export function normalizeWebhookHeaders(headers: unknown): Record<string, string> {
if (headers === undefined || headers === null) return {};
if (typeof headers !== "object" || Array.isArray(headers)) throw new Error("headers must be an object");
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(headers as Record<string, unknown>)) {
if (!/^[A-Za-z0-9-]{1,64}$/.test(k)) throw new Error(`invalid header name: ${k}`);
if (v === undefined || v === null) continue;
if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") {
throw new Error(`invalid header value for: ${k}`);
}
out[k] = String(v);
}
return out;
}
function safeParseHeaders(s: string | null): Record<string, string> {
if (!s) return {};
try {
return normalizeWebhookHeaders(JSON.parse(s));
} catch {
return {};
}
}
/** # 成员变量: 邮箱/域名行的 Webhook 列形态 **/
interface WebhookColumns {
webhook_url: string | null;
webhook_headers: string | null;
webhook_key_enc: string | null;
}
/** # 函数
* 三级 Webhook 解析:邮箱 -> 域名 -> 全局(settings),命中第一级即返回
* 参数: env - 运行环境;mailboxId - 邮件命中的邮箱(可为 null)
* 返回值: 命中的配置;三级都未配置时 null(不投递)
*/
export async function resolveWebhookConfig(env: Env, mailboxId: string | null): Promise<WebhookConfig | null> {
if (mailboxId) {
const mb = await env.DB.prepare(
"SELECT webhook_url, webhook_headers, webhook_key_enc, domain_id FROM mailboxes WHERE id = ?",
)
.bind(mailboxId)
.first<WebhookColumns & { domain_id: string }>();
if (mb?.webhook_url) {
return {
targetType: "mailbox",
url: mb.webhook_url,
headers: safeParseHeaders(mb.webhook_headers),
key: mb.webhook_key_enc ? await decryptSecret(env, mb.webhook_key_enc) : "",
};
}
if (mb?.domain_id) {
const dom = await env.DB.prepare(
"SELECT webhook_url, webhook_headers, webhook_key_enc FROM domains WHERE id = ?",
)
.bind(mb.domain_id)
.first<WebhookColumns>();
if (dom?.webhook_url) {
return {
targetType: "domain",
url: dom.webhook_url,
headers: safeParseHeaders(dom.webhook_headers),
key: dom.webhook_key_enc ? await decryptSecret(env, dom.webhook_key_enc) : "",
};
}
}
}
const raw = await getSetting(env, GLOBAL_WEBHOOK_SETTING);
if (!raw) return null;
try {
const cfg = JSON.parse(raw) as { url?: string; headers?: Record<string, string> };
if (!cfg.url) return null;
return { targetType: "global", url: cfg.url, headers: cfg.headers ?? {}, key: await getGlobalWebhookKey(env) };
} catch {
return null;
}
}
/** # 函数
* 读取全局 Webhook key(密文存 settings)
*/
export async function getGlobalWebhookKey(env: Env): Promise<string> {
const enc = await getSetting(env, "global_webhook_key_enc");
return enc ? await decryptSecret(env, enc) : "";
}
/** # 函数
* 保存全局 Webhook 配置到 settings(url/headers 明文 JSON,key 加密)
* 参数: cfg.url 为空串表示停用全局 Webhook;key 为 undefined 表示保留旧 key
*/
export async function saveGlobalWebhook(
env: Env,
cfg: { url: string; headers?: Record<string, string>; key?: string },
): Promise<void> {
await setSetting(
env,
GLOBAL_WEBHOOK_SETTING,
JSON.stringify({ url: cfg.url, headers: cfg.headers ?? {} }),
);
if (cfg.key !== undefined) {
if (cfg.key === "") {
await env.DB.prepare("DELETE FROM settings WHERE key = 'global_webhook_key_enc'").run();
} else {
await setSetting(env, "global_webhook_key_enc", await encryptSecret(env, cfg.key));
}
}
}
/** # 函数
* 保存邮箱/域名级 Webhook 配置(url 为空串表示清除该级配置)
*/
export async function saveScopedWebhook(
env: Env,
table: "mailboxes" | "domains",
id: string,
cfg: { url: string; headers?: Record<string, string>; key?: string },
): Promise<void> {
if (!["mailboxes", "domains"].includes(table)) throw new Error("bad table");
const headersJson = JSON.stringify(cfg.headers ?? {});
const keyEnc = cfg.key !== undefined ? (cfg.key === "" ? null : await encryptSecret(env, cfg.key)) : undefined;
if (!cfg.url) {
// 清除该级配置
await env.DB.prepare(
`UPDATE ${table} SET webhook_url = NULL, webhook_headers = NULL, webhook_key_enc = NULL, updated_at = ? WHERE id = ?`,
)
.bind(nowIso(), id)
.run();
return;
}
if (keyEnc === undefined) {
await env.DB.prepare(
`UPDATE ${table} SET webhook_url = ?, webhook_headers = ?, updated_at = ? WHERE id = ?`,
)
.bind(cfg.url, headersJson, nowIso(), id)
.run();
} else {
await env.DB.prepare(
`UPDATE ${table} SET webhook_url = ?, webhook_headers = ?, webhook_key_enc = ?, updated_at = ? WHERE id = ?`,
)
.bind(cfg.url, headersJson, keyEnc, nowIso(), id)
.run();
}
}
/** # 函数
* webhook key 入库密文(供入队时固化)
*/
async function encryptWebhookKey(env: Env, key: string): Promise<string | null> {
return key ? aesEncrypt(await getAesKey(env), key) : null;
}
/** # 函数
* 解密投递行固化的 key
*/
async function decryptWebhookKey(env: Env, blob: string | null): Promise<string> {
return blob ? await aesDecrypt(await getAesKey(env), blob) : "";
}
/** # 成员变量: Webhook 投递 payload 结构(docs/07) **/ /** # 成员变量: Webhook 投递 payload 结构(docs/07) **/
export function buildWebhookPayload(email: { export function buildWebhookPayload(email: {
id: string; id: string;
@@ -53,30 +237,43 @@ function safeParseArray(s: string | null): string[] {
/** # 函数 /** # 函数
* 执行一次投递;返回是否成功与错误信息(不修改库) * 执行一次投递;返回是否成功与错误信息(不修改库)
* 参数: config - 固化在投递行上的目标配置
*/ */
export async function attemptDelivery( export async function attemptDelivery(
webhook: { id: string; endpoint: string; secret: string }, config: { url: string; headers: Record<string, string>; key: string },
deliveryId: string, deliveryId: string,
payload: Record<string, unknown>, payload: Record<string, unknown>,
resolve?: HostResolver, resolve?: HostResolver,
allowPrivateHosts = false, allowPrivateHosts = false,
): Promise<{ ok: boolean; status?: number; error?: string }> { ): Promise<{ ok: boolean; status?: number; error?: string }> {
const unsafe = await assertWebhookUrlSafe(webhook.endpoint, resolve, allowPrivateHosts); const unsafe = await assertWebhookUrlSafe(config.url, resolve, allowPrivateHosts);
if (unsafe) return { ok: false, error: `ssrf guard: ${unsafe}` }; if (unsafe) return { ok: false, error: `ssrf guard: ${unsafe}` };
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
const timestamp = String(Math.floor(Date.now() / 1000)); const timestamp = String(Math.floor(Date.now() / 1000));
const signature = await hmacSha256Hex(webhook.secret, `${timestamp}.${body}`);
try { // 组装请求头:标准头 -> key 的默认 Authorization -> 用户自定义头(可覆盖标准头,但不能去 Content-Type)
const resp = await fetch(webhook.endpoint, { // 未配置 key 时无法协商签名密钥,跳过签名头(仅保留事件标识)
method: "POST", const headers: Record<string, string> = {
headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
[WEBHOOK_HEADERS.event]: "email.received", [WEBHOOK_HEADERS.event]: "email.received",
[WEBHOOK_HEADERS.delivery]: deliveryId, [WEBHOOK_HEADERS.delivery]: deliveryId,
[WEBHOOK_HEADERS.timestamp]: timestamp, };
[WEBHOOK_HEADERS.signature]: signature, if (config.key) {
}, headers[WEBHOOK_HEADERS.timestamp] = timestamp;
headers[WEBHOOK_HEADERS.signature] = await hmacSha256Hex(config.key, `${timestamp}.${body}`);
}
const customHasAuth = Object.keys(config.headers).some((k) => k.toLowerCase() === "authorization");
if (config.key && !customHasAuth) headers["Authorization"] = `Bearer ${config.key}`;
for (const [k, v] of Object.entries(config.headers)) {
if (k.toLowerCase() === "content-type") continue;
headers[k] = v;
}
try {
const resp = await fetch(config.url, {
method: "POST",
headers,
body, body,
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS), signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
}); });
@@ -95,6 +292,23 @@ export function nextRetryIso(attemptCount: number): string {
return new Date(Date.now() + delay).toISOString(); return new Date(Date.now() + delay).toISOString();
} }
/** # 成员变量: 投递循环的行结构 **/
interface DueDeliveryRow {
delivery_id: string;
url: string;
headers: string | null;
key_enc: string | null;
attempt_count: 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;
}
/** # 函数 /** # 函数
* Cron 驱动的投递循环:取到期 delivery 逐个投递并落状态。 * Cron 驱动的投递循环:取到期 delivery 逐个投递并落状态。
* 返回处理统计(供日志与 admin 接口)。 * 返回处理统计(供日志与 admin 接口)。
@@ -107,11 +321,10 @@ export async function processDueDeliveries(
): Promise<{ processed: number; success: number; failed: number; exhausted: number }> { ): Promise<{ processed: number; success: number; failed: number; exhausted: number }> {
const now = nowIso(); const now = nowIso();
const due = await env.DB.prepare( 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, `SELECT d.id AS delivery_id, d.url, d.headers, d.key_enc, d.attempt_count,
e.id AS email_id, e.mailbox_id, e.from_address, e.to_addresses, e.subject, e.text_body, 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 e.received_at, e.expires_at
FROM webhook_deliveries d FROM webhook_deliveries d
JOIN webhooks w ON w.id = d.webhook_id
JOIN emails e ON e.id = d.email_id JOIN emails e ON e.id = d.email_id
WHERE d.status IN ('pending', 'failed') WHERE d.status IN ('pending', 'failed')
AND d.attempt_count < ? AND d.attempt_count < ?
@@ -120,22 +333,7 @@ export async function processDueDeliveries(
LIMIT ?`, LIMIT ?`,
) )
.bind(WEBHOOK_MAX_ATTEMPTS, now, limit) .bind(WEBHOOK_MAX_ATTEMPTS, now, limit)
.all<{ .all<DueDeliveryRow>();
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 success = 0;
let failed = 0; let failed = 0;
@@ -149,7 +347,6 @@ export async function processDueDeliveries(
exhausted++; exhausted++;
continue; continue;
} }
if (!row.enabled) continue;
const mailbox = row.mailbox_id const mailbox = row.mailbox_id
? ( ? (
@@ -168,7 +365,7 @@ export async function processDueDeliveries(
receivedAt: row.received_at, receivedAt: row.received_at,
}); });
const result = await attemptDelivery( const result = await attemptDelivery(
{ id: row.webhook_id, endpoint: row.endpoint, secret: row.secret }, { url: row.url, headers: safeParseHeaders(row.headers), key: await decryptWebhookKey(env, row.key_enc) },
row.delivery_id, row.delivery_id,
payload, payload,
resolve, resolve,
@@ -200,3 +397,32 @@ export async function processDueDeliveries(
} }
return { processed: due.results.length, success, failed, exhausted }; return { processed: due.results.length, success, failed, exhausted };
} }
/** # 函数
* 入队:为邮件按三级解析结果创建一条投递记录(未配置任何 Webhook 则不入队)
* 返回值: 命中的层级或 null
*/
export async function enqueueDeliveryForEmail(
env: Env,
emailId: string,
mailboxId: string | null,
): Promise<WebhookConfig["targetType"] | null> {
const config = await resolveWebhookConfig(env, mailboxId);
if (!config) return null;
await env.DB.prepare(
`INSERT INTO webhook_deliveries (id, email_id, target_type, url, headers, key_enc, status, attempt_count, next_retry_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
)
.bind(
ids.delivery(),
emailId,
config.targetType,
config.url,
JSON.stringify(config.headers),
await encryptWebhookKey(env, config.key),
nowIso(),
nowIso(),
)
.run();
return config.targetType;
}
+33
View File
@@ -0,0 +1,33 @@
-- v1.1.0:账号化重构 + 三级 Webhook(邮箱 -> 域名 -> 全局)
-- 节点在 UI/API 层更名「账号」(表名保持 nodes,兼容既有 Node 内部协议)
-- 域名级 Webhook 与 Email Workers 配置状态
ALTER TABLE domains ADD COLUMN webhook_url TEXT;
ALTER TABLE domains ADD COLUMN webhook_headers TEXT; -- JSON: 自定义请求头
ALTER TABLE domains ADD COLUMN webhook_key_enc TEXT; -- webhook key AES-GCM 密文
ALTER TABLE domains ADD COLUMN setup_error TEXT; -- 「设置 Email Workers」失败原因
-- 邮箱级 Webhook 配置
ALTER TABLE mailboxes ADD COLUMN webhook_url TEXT;
ALTER TABLE mailboxes ADD COLUMN webhook_headers TEXT;
ALTER TABLE mailboxes ADD COLUMN webhook_key_enc TEXT;
-- Webhook 独立实体移除:配置落在 邮箱/域名/全局 三级,投递行携带解析后的目标配置
DROP TABLE IF EXISTS webhook_deliveries;
DROP TABLE IF EXISTS webhooks;
CREATE TABLE IF NOT EXISTS webhook_deliveries (
id TEXT PRIMARY KEY,
email_id TEXT NOT NULL,
target_type TEXT NOT NULL, -- mailbox | domain | global
url TEXT NOT NULL,
headers TEXT, -- JSON: 自定义请求头
key_enc TEXT, -- webhook key 密文(用于 HMAC 签名与 Authorization: Bearer)
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_email ON webhook_deliveries(email_id);
+5 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "wmailhub", "name": "wmailhub",
"version": "1.0.0", "version": "1.1.0",
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console + Node + Email Worker)", "description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console + Node + Email Worker)",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun run scripts/dev.ts", "dev": "bun run scripts/dev.ts",
"build": "bun run scripts/build-frontend.ts", "build": "vite build",
"test": "bun test", "test": "bun test",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
@@ -22,9 +22,11 @@
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.1.1",
"bun-types": "^1.2.0", "bun-types": "^1.2.0",
"playwright-core": "^1.63.0", "playwright-core": "^1.63.0",
"typescript": "^5.9.0" "typescript": "^5.9.0",
"vite": "^8.3.1"
}, },
"engines": { "engines": {
"bun": ">=1.2.0" "bun": ">=1.2.0"
-36
View File
@@ -1,36 +0,0 @@
/**
* 前端构建: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");
+30 -33
View File
@@ -4,9 +4,9 @@
* bun run dev -- --reset # 清空本地数据后全新安装 * bun run dev -- --reset # 清空本地数据后全新安装
* *
* 流程: * 流程:
* 1. 构建前端 * 1. 构建前端(vite build)
* 2. 启动 Console(:18787,自动迁移 + admin/admin 种子) * 2. 启动 Console(:18787,自动迁移 + admin/admin 种子)
* 3. 确保存在本地 Node(无则创建)并完成 sim 模式 OAuth 自动安装(幂等) * 3. 确保存在本地账号(无则 OAuth sim 自动创建账号 + 同步域名),并逐域名配置 Email Workers
* 4. 启动 Node(:18788,enrollment 换取身份,config.json 0600 落盘) * 4. 启动 Node(:18788,enrollment 换取身份,config.json 0600 落盘)
* 5. 启动 Email Worker(:18789,转发到 Node) * 5. 启动 Email Worker(:18789,转发到 Node)
* 6. Ctrl-C 一起退出 * 6. Ctrl-C 一起退出
@@ -79,54 +79,51 @@ if (!loginResp.ok) throw new Error("admin 登录失败");
const cookie = (loginResp.headers.get("set-cookie") ?? "").split(";")[0]; const cookie = (loginResp.headers.get("set-cookie") ?? "").split(";")[0];
const authHeaders = { Cookie: cookie, "Content-Type": "application/json" }; const authHeaders = { Cookie: cookie, "Content-Type": "application/json" };
// 3. 确保本地 Node 存在 // 3. 确保本地账号存在(无则走 OAuth 自动创建);拿到 ingress secret 与 enrollment token
const nodesResp = await fetch(`${BASE}/api/nodes`, { headers: authHeaders }); const accountsResp = await fetch(`${BASE}/api/accounts`, { headers: authHeaders });
const existing = ((await nodesResp.json()) as { items: { id: string; name: string }[] }).items; const existing = ((await accountsResp.json()) as { items: { id: string; name: string }[] }).items;
let nodeId: string; let nodeId: string;
let ingressSecret: string; let ingressSecret: string;
let enrollmentToken: string | null = null; let enrollmentToken: string | null = null;
if (existing.length > 0) { if (existing.length > 0) {
nodeId = existing[0].id; nodeId = existing[0].id;
ingressSecret = ((await (await fetch(`${BASE}/api/nodes/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret; ingressSecret = ((await (await fetch(`${BASE}/api/accounts/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret;
// 无论 config.json 是否存在,都发一个新 token:有身份的 Node 会忽略它,无身份的用它 enrollment // 无论 config.json 是否存在,都发一个新 token:有身份的 Node 会忽略它,无身份的用它 enrollment
const rotated = (await ( const rotated = (await (
await fetch(`${BASE}/api/nodes/${nodeId}/rotate`, { method: "POST", headers: authHeaders }) await fetch(`${BASE}/api/accounts/${nodeId}/rotate`, { method: "POST", headers: authHeaders })
).json()) as { enrollment_token: string }; ).json()) as { enrollment_token: string };
enrollmentToken = rotated.enrollment_token; enrollmentToken = rotated.enrollment_token;
console.log(`[dev] 复用既有节点 ${nodeId}`); console.log(`[dev] 复用既有账号 ${nodeId}`);
} else { } else {
const created = (await ( // 添加账号:OAuth start 不带 node_id,服务端自动创建账号
await fetch(`${BASE}/api/nodes`, { const startResp = await fetch(`${BASE}/api/oauth/cloudflare/start`, { headers: authHeaders });
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 { state } = (await startResp.json()) as { state: string };
const approve = await fetch(`${BASE}/api/oauth/cloudflare/sim/approve`, { const approve = await fetch(`${BASE}/api/oauth/cloudflare/sim/approve`, {
method: "POST", method: "POST",
headers: authHeaders, headers: authHeaders,
body: JSON.stringify({ state }), body: JSON.stringify({ state }),
}); });
const approveRes = (await approve.json()) as { ok: boolean; error?: string }; const approveRes = (await approve.json()) as { ok: boolean; error?: string; node_id: string };
if (!approveRes.ok) throw new Error(`自动安装失败:${approveRes.error}`); if (!approveRes.ok) throw new Error(`自动创建账号失败:${approveRes.error}`);
console.log("[dev] Cloudflare OAuth(sim)自动安装完成:Email Worker / Routing / Domain / Mailbox 就绪"); nodeId = approveRes.node_id;
} else { ingressSecret = ((await (await fetch(`${BASE}/api/accounts/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret;
console.log(`[dev] 节点已安装(status=${nodeDetail.node.status}),跳过安装`); const rotated = (await (
await fetch(`${BASE}/api/accounts/${nodeId}/rotate`, { method: "POST", headers: authHeaders })
).json()) as { enrollment_token: string };
enrollmentToken = rotated.enrollment_token;
console.log(`[dev] OAuth(sim)已创建账号 ${nodeId} 并同步域名`);
}
// 4. 域名同步后逐域名配置 Email Workers(sim 模式记录;幂等)
const domainsResp = await fetch(`${BASE}/api/domains`, { headers: authHeaders });
const domains = ((await domainsResp.json()) as { items: { id: string; name: string; status: string }[] }).items;
for (const d of domains.filter((x) => x.status !== "active")) {
const setup = (await (
await fetch(`${BASE}/api/domains/${d.id}/setup`, { method: "POST", headers: authHeaders })
).json()) as { ok: boolean; error?: string };
if (!setup.ok) throw new Error(`域名 ${d.name} 配置 Email Workers 失败:${setup.error}`);
console.log(`[dev] 域名 ${d.name} Email Workers 已配置`);
} }
// 5. Node(首次用 enrollment token 换身份;之后 config.json 已有身份) // 5. Node(首次用 enrollment token 换身份;之后 config.json 已有身份)
+1 -2
View File
@@ -147,7 +147,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
expect(resp.status).toBe(403); expect(resp.status).toBe(403);
}); });
test("initialize + tools/list 返回六个工具与 serverInfo", async () => { test("initialize + tools/list 返回五个工具与 serverInfo", async () => {
const t = await makeTestEnv(); const t = await makeTestEnv();
const cookie = await loginCookie(t); const cookie = await loginCookie(t);
const mcpKey = await createKey(t, cookie, "mcp"); const mcpKey = await createKey(t, cookie, "mcp");
@@ -164,7 +164,6 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
"get_email", "get_email",
"create_mailbox", "create_mailbox",
"delete_mailbox", "delete_mailbox",
"list_webhooks",
]); ]);
}); });
+1 -1
View File
@@ -27,7 +27,7 @@ describe("用户认证(默认 admin/admin)", () => {
test("无会话访问受保护接口返回 401", async () => { test("无会话访问受保护接口返回 401", async () => {
const t = await makeTestEnv(); const t = await makeTestEnv();
const resp = await t.app.fetch(new Request("http://localhost/api/nodes"), t.env); const resp = await t.app.fetch(new Request("http://localhost/api/accounts"), t.env);
expect(resp.status).toBe(401); expect(resp.status).toBe(401);
}); });
+26
View File
@@ -0,0 +1,26 @@
/** E2E 临时接收器:18790,打印签名/Authorization 校验结果 */
const KEY = "wmh_e2e_key_12345";
Bun.serve({
port: 18790,
async fetch(req) {
const body = await req.text();
const ts = req.headers.get("X-WmailHub-Timestamp") ?? "";
const sig = req.headers.get("X-WmailHub-Signature") ?? "";
const { hmacSha256Hex } = await import("../../shared/src/index.ts");
const expect = await hmacSha256Hex(KEY, `${ts}.${body}`);
const payload = JSON.parse(body) as Record<string, unknown>;
console.log(
JSON.stringify({
got: "webhook",
auth: req.headers.get("Authorization"),
signature_valid: sig === expect,
event: payload.event,
subject: payload.subject,
mailbox: payload.mailbox,
custom_header: req.headers.get("X-E2E"),
}),
);
return Response.json({ ok: true });
},
});
console.log("receiver on :18790");
+8 -2
View File
@@ -15,8 +15,14 @@ import { encryptSecret } from "../console/src/db.ts";
import { hashPassword, sha256Hex, signNodeRequest } from "../shared/src/index.ts"; import { hashPassword, sha256Hex, signNodeRequest } from "../shared/src/index.ts";
import { ids } from "../shared/src/index.ts"; import { ids } from "../shared/src/index.ts";
/** # 成员变量: 迁移 SQL(一次读取,多处使用) **/ /** # 成员变量: 迁移 SQL(按文件名顺序全部应用,一次读取) **/
export const MIGRATION_SQL = await Bun.file(new URL("../migrations/0001_init.sql", import.meta.url)).text(); export const MIGRATION_SQL = (
await Promise.all(
[...new Bun.Glob("migrations/*.sql").scanSync({ cwd: new URL("..", import.meta.url).pathname })]
.sort()
.map(async (f) => await Bun.file(new URL(`../${f}`, import.meta.url)).text()),
)
).join("\n");
/** # 成员变量: 测试邮件样例(plain + html + 1 附件) **/ /** # 成员变量: 测试邮件样例(plain + html + 1 附件) **/
export const TEST_MAIL = `From: Alice <alice@example.org>\r export const TEST_MAIL = `From: Alice <alice@example.org>\r
+54 -43
View File
@@ -1,25 +1,15 @@
/** Cloudflare OAuth(本地 sim 模式)测试:state CSRF、自动安装幂等、连接存储 */ /** Cloudflare OAuth(本地 sim 模式)测试:自动建账号、域名同步、state CSRF、setup Email Workers、凭据存储 */
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts"; import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
describe("Cloudflare OAuth(sim 模式)", () => { describe("Cloudflare OAuth(sim 模式)", () => {
test("start 生成 state;sim approve 完成自动安装(OAuth→identity→discover→worker→routing→domains→mailboxes)", async () => { test("start 免 node_id 自动建账号;approve 同步全部域名;setup 配置 Email Workers", async () => {
const t = await makeTestEnv(); const t = await makeTestEnv();
const cookie = await loginCookie(t); const cookie = await loginCookie(t);
// Add Node // 添加账号:OAuth start 不带 node_id(服务端自动创建账号)
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 ( const start = (await (
await authedFetch(t, cookie, `http://localhost/api/oauth/cloudflare/start?node_id=${created.node_id}`) await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
).json()) as { url: string; state: string }; ).json()) as { url: string; state: string };
expect(start.url).toContain("/oauth/sim/authorize?state="); expect(start.url).toContain("/oauth/sim/authorize?state=");
@@ -30,31 +20,56 @@ describe("Cloudflare OAuth(sim 模式)", () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: start.state }), body: JSON.stringify({ state: start.state }),
}) })
).json()) as { ok: boolean; error?: string }; ).json()) as { ok: boolean; error?: string; node_id: string };
expect(approve.ok).toBe(true); expect(approve.ok).toBe(true);
// 安装结果 // 安装结果:发现 + 域名同步完成
const detail = (await ( const detail = (await (
await authedFetch(t, cookie, `http://localhost/api/nodes/${created.node_id}`) await authedFetch(t, cookie, `http://localhost/api/accounts/${approve.node_id}`)
).json()) as { ).json()) as {
node: { status: string; cloudflare_account_id: string | null }; account: { status: string; cloudflare_account_id: string | null; name: string };
steps: Record<string, { status: string }>; steps: Record<string, { status: string }>;
domains: { name: string }[]; domains: { name: string }[];
}; };
expect(detail.node.status).toBe("offline"); // 等待 Node 心跳上线 expect(detail.account.status).toBe("offline"); // 等待 Node 心跳上线
expect(detail.node.cloudflare_account_id).toBe("sim-account-0001"); expect(detail.account.cloudflare_account_id).toBe("sim-account-0001");
for (const key of ["oauth", "identity", "discover", "email_worker", "email_routing", "domains", "mailboxes"]) { expect(detail.account.name).toBe("WmailHub Sim Account"); // 自动命名替换为 CF 账户名
for (const key of ["oauth", "identity", "discover", "domains"]) {
expect(detail.steps[key].status).toBe("done"); expect(detail.steps[key].status).toBe("done");
} }
expect(detail.domains.map((d) => d.name)).toContain("example.test"); // OAuth 只同步域名,不自动部署 Email Worker(按域名手动触发)
expect(detail.domains.map((d) => d.name)).toEqual(
expect.arrayContaining(["example.test", "demo.test", "mailbox.dev"]),
);
const domList = (await (
await authedFetch(t, cookie, "http://localhost/api/domains")
).json()) as { items: { id: string; name: string; status: string }[] };
// 种子环境自带一个 acct-1 下的 example.test(active);OAuth 同步出的新域名均为 pending
const synced = domList.items.filter((d) => ["demo.test", "mailbox.dev"].includes(d.name));
expect(synced.length).toBe(2);
for (const d of synced) expect(d.status).toBe("pending");
// catch-all mailbox 已创建 // catch-all 邮箱已随同步创建
const mb = await t.env.DB.prepare("SELECT address FROM mailboxes WHERE is_catch_all = 1").all<{ address: string }>(); 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"); expect(mb.results.map((r) => r.address)).toContain("*@example.test");
// sim 资源记录(Email Worker / Routing rule) // setup:为同步出的 example.test 配置 Email Workers(Worker + Routing + catch-all 规则)
const example = domList.items.find((d) => d.name === "example.test" && d.status === "pending")!;
const setup = (await (
await authedFetch(t, cookie, `http://localhost/api/domains/${example.id}/setup`, { method: "POST" })
).json()) as { ok: boolean; error?: string };
expect(setup.ok).toBe(true);
const after = (await (
await authedFetch(t, cookie, "http://localhost/api/domains")
).json()) as { items: { id: string; name: string; status: string; worker_id: string | null }[] };
const exampleAfter = after.items.find((d) => d.id === example.id)!;
expect(exampleAfter.status).toBe("active");
expect(exampleAfter.worker_id).toContain("wmailhub-email-");
// sim 资源记录(Email Worker / Routing rule 只在 setup 后出现)
const sim = await t.env.DB.prepare("SELECT kind FROM cf_sim_resources").all<{ kind: string }>(); const sim = await t.env.DB.prepare("SELECT kind FROM cf_sim_resources").all<{ kind: string }>();
const kinds = sim.results.map((r) => r.kind); const kinds = sim.results.map((r) => r.kind);
expect(kinds).toContain("zone");
expect(kinds).toContain("email_worker"); expect(kinds).toContain("email_worker");
expect(kinds).toContain("routing_rule"); expect(kinds).toContain("routing_rule");
@@ -66,18 +81,11 @@ describe("Cloudflare OAuth(sim 模式)", () => {
expect(conn!.encrypted_access_token.startsWith("sim_")).toBe(false); expect(conn!.encrypted_access_token.startsWith("sim_")).toBe(false);
}); });
test("state 一次性:重放回调 state 被拒(OAuth CSRF 防护)", async () => { test("state 一次性:重放 approve 被拒(OAuth CSRF 防护)", async () => {
const t = await makeTestEnv(); const t = await makeTestEnv();
const cookie = await loginCookie(t); 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 ( const { state } = (await (
await authedFetch(t, cookie, `http://localhost/api/oauth/cloudflare/start?node_id=${created.node_id}`) await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start")
).json()) as { state: string }; ).json()) as { state: string };
const first = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", { const first = await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", {
@@ -111,32 +119,35 @@ describe("Cloudflare OAuth(sim 模式)", () => {
test("enrollment:token 换取身份,凭证明文仅返回一次且库中只存摘要/密文", async () => { test("enrollment:token 换取身份,凭证明文仅返回一次且库中只存摘要/密文", async () => {
const t = await makeTestEnv(); const t = await makeTestEnv();
const cookie = await loginCookie(t); const cookie = await loginCookie(t);
const created = (await ( // 自动创建账号后,用 rotate 取得 enrollment token
await authedFetch(t, cookie, "http://localhost/api/nodes", { await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start");
method: "POST", const accounts = (await (
headers: { "Content-Type": "application/json" }, await authedFetch(t, cookie, "http://localhost/api/accounts")
body: JSON.stringify({ name: "n2" }), ).json()) as { items: { id: string }[] };
}) const accountId = accounts.items[0].id;
).json()) as { node_id: string; enrollment_token: string }; const rotated = (await (
await authedFetch(t, cookie, `http://localhost/api/accounts/${accountId}/rotate`, { method: "POST" })
).json()) as { enrollment_token: string };
expect(rotated.enrollment_token.startsWith("wenr_")).toBe(true);
const enroll = (await ( const enroll = (await (
await t.app.fetch( await t.app.fetch(
new Request("http://localhost/api/internal/enroll", { new Request("http://localhost/api/internal/enroll", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enrollment_token: created.enrollment_token }), body: JSON.stringify({ enrollment_token: rotated.enrollment_token }),
}), }),
t.env, t.env,
) )
).json()) as { node_id: string; credential_secret: string; ingress_secret: string }; ).json()) as { node_id: string; credential_secret: string; ingress_secret: string };
expect(enroll.node_id).toBe(created.node_id); expect(enroll.node_id).toBe(accountId);
expect(enroll.credential_secret.startsWith("wns_")).toBe(true); expect(enroll.credential_secret.startsWith("wns_")).toBe(true);
// 库中不存明文 secret // 库中不存明文 secret
const cred = await t.env.DB.prepare( const cred = await t.env.DB.prepare(
"SELECT secret_hash, secret_enc FROM node_credentials WHERE node_id = ? AND status='active'", "SELECT secret_hash, secret_enc FROM node_credentials WHERE node_id = ? AND status='active'",
) )
.bind(created.node_id) .bind(accountId)
.first<{ secret_hash: string; secret_enc: string }>(); .first<{ secret_hash: string; secret_enc: string }>();
expect(cred?.secret_hash).not.toBe(enroll.credential_secret); expect(cred?.secret_hash).not.toBe(enroll.credential_secret);
expect(cred?.secret_enc).not.toContain(enroll.credential_secret); expect(cred?.secret_enc).not.toContain(enroll.credential_secret);
+1 -1
View File
@@ -165,7 +165,7 @@ describe("TTL 清理与 orphan", () => {
) )
.run(); .run();
await t.env.DB.prepare( 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, ?)", "INSERT INTO webhook_deliveries (id, email_id, target_type, url, status, attempt_count, created_at) VALUES ('dlv_orphan', 'msg_gone', 'global', 'https://hooks.example.com/x', 'pending', 0, ?)",
) )
.bind(now) .bind(now)
.run(); .run();
+116
View File
@@ -0,0 +1,116 @@
/** 设置测试:全局 Webhook 读写与 key 语义、默认 Ingress、修改密码 */
import { describe, expect, test } from "bun:test";
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
describe("设置(/api/settings)", () => {
test("全局 Webhook:保存 -> 读取(仅回 last4);key 缺省保留、空格清除", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 保存(带 key)
const save = await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ webhook: { url: "https://hooks.example.com/a", headers: { "X-A": "1" }, key: "wmh_key_abcdefgh" } }),
});
expect(save.status).toBe(200);
let info = (await (
await authedFetch(t, cookie, "http://localhost/api/settings")
).json()) as { webhook: { url: string; has_key: boolean; key_last4: string; headers: Record<string, string> } };
expect(info.webhook.url).toBe("https://hooks.example.com/a");
expect(info.webhook.has_key).toBe(true);
expect(info.webhook.key_last4).toBe("efgh");
expect(info.webhook.headers).toEqual({ "X-A": "1" });
// 再次保存不带 key:key 保留
await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ webhook: { url: "https://hooks.example.com/b" } }),
});
info = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as typeof info;
expect(info.webhook.url).toBe("https://hooks.example.com/b");
expect(info.webhook.has_key).toBe(true);
// key 传空格:清除 key
await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ webhook: { url: "https://hooks.example.com/b", key: " " } }),
});
info = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as typeof info;
expect(info.webhook.has_key).toBe(false);
// url 置空:停用全局 webhook
await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ webhook: { url: "" } }),
});
info = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as typeof info;
expect(info.webhook.url).toBe("");
});
test("默认 Node Ingress 地址保存与读取", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const save = await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_ingress_url: "https://node.example.com/ingress/email" }),
});
expect(save.status).toBe(200);
const info = (await (
await authedFetch(t, cookie, "http://localhost/api/settings")
).json()) as { default_ingress_url: string };
expect(info.default_ingress_url).toBe("https://node.example.com/ingress/email");
// 非法地址被拒
const bad = await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_ingress_url: "not-a-url" }),
});
expect(bad.status).toBe(400);
});
test("修改密码:原密码错误被拒;成功后新密码可登录", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const bad = await authedFetch(t, cookie, "http://localhost/api/settings/password", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old_password: "wrong", new_password: "newpass123" }),
});
expect(bad.status).toBe(400);
const ok = await authedFetch(t, cookie, "http://localhost/api/settings/password", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old_password: "admin", new_password: "newpass123" }),
});
expect(ok.status).toBe(200);
// 旧密码登录失败,新密码登录成功
const oldLogin = await t.app.fetch(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "admin" }),
}),
t.env,
);
expect(oldLogin.status).toBe(401);
const newLogin = await t.app.fetch(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "newpass123" }),
}),
t.env,
);
expect(newLogin.status).toBe(200);
});
});
+35 -18
View File
@@ -1,6 +1,6 @@
/** /**
* UI 冒烟测试(Playwright headless): * UI 冒烟测试(Playwright headless):
* 登录 admin/admin → 概览/节点/域名/邮箱/邮件/Webhooks/Keys 各页渲染 → 截图。 * 登录 admin/admin → 概览/账号/域名/邮箱/邮件 各页渲染 → 设置模态窗口 → 生成 Key → 截图。
* bun tests/ui-smoke.ts * bun tests/ui-smoke.ts
*/ */
@@ -42,45 +42,62 @@ await page.click("button[type=submit]");
await page.waitForURL(`${BASE}/`, { timeout: 8000 }); await page.waitForURL(`${BASE}/`, { timeout: 8000 });
await page.waitForSelector("text=概览", { timeout: 8000 }); await page.waitForSelector("text=概览", { timeout: 8000 });
check("登录成功进入概览页", true); check("登录成功进入概览页", true);
// 品牌区版本号 tag(v1.1.0,来源 package.json)
const versionTag = await page.locator(".brand-version").textContent();
check("LOGO 版本号 tag 显示", Boolean(versionTag && /^v\d+\.\d+\.\d+$/.test(versionTag.trim())), versionTag?.trim());
await page.waitForTimeout(600); await page.waitForTimeout(600);
await page.screenshot({ path: `${SHOT_DIR}/02-dashboard.png` }); await page.screenshot({ path: `${SHOT_DIR}/02-dashboard.png` });
// 3. 各页面导航渲染 // 3. 各页面导航渲染
const pages = [ const pages = [
{ path: "/nodes", expect: "节点管理", shot: "03-nodes.png" }, { path: "/accounts", expect: "账号", shot: "03-accounts.png" },
{ path: "/domains", expect: "域名管理", shot: "04-domains.png" }, { path: "/domains", expect: "域名", shot: "04-domains.png" },
{ path: "/mailboxes", expect: "邮箱管理", shot: "05-mailboxes.png" }, { path: "/mailboxes", expect: "邮箱", shot: "05-mailboxes.png" },
{ path: "/emails", expect: "邮件", shot: "06-emails.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) { for (const p of pages) {
await page.goto(`${BASE}${p.path}`, { waitUntil: "domcontentloaded" }); await page.goto(`${BASE}${p.path}`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(`text=${p.expect}`, { timeout: 8000 }); await page.waitForSelector(`text=${p.expect}`, { timeout: 8000 });
await page.waitForTimeout(500); await page.waitForTimeout(500);
const hasTable = (await page.locator(".ant-table").count()) > 0; const hasTable = (await page.locator(".ant-table").count()) > 0;
check(`页面 ${p.path} 渲染${hasTable ? "(含表格)" : ""}`, true); const hasSearch = (await page.locator("input[placeholder*='搜索']").count()) > 0;
check(`页面 ${p.path} 渲染${hasTable ? "(含表格)" : ""}${hasSearch ? "(含搜索框)" : ""}`, hasTable);
await page.screenshot({ path: `${SHOT_DIR}/${p.shot}` }); await page.screenshot({ path: `${SHOT_DIR}/${p.shot}` });
} }
// 4. 邮件详情(点击第一行) // 4. 域名详情抽屉(点击第一行:Email Workers + Webhook 配置)
await page.goto(`${BASE}/domains`, { 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 });
await page.waitForSelector("text=域名级 Webhook", { timeout: 8000 });
check("域名详情抽屉(Email Workers + Webhook 配置)", true);
await page.screenshot({ path: `${SHOT_DIR}/07-domain-drawer.png` });
await page.keyboard.press("Escape");
// 5. 邮件详情(点击第一行)
await page.goto(`${BASE}/emails`, { waitUntil: "domcontentloaded" }); await page.goto(`${BASE}/emails`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table-row", { timeout: 8000 }); await page.waitForSelector(".ant-table-row", { timeout: 8000 });
await page.locator(".ant-table-row").first().click(); await page.locator(".ant-table-row").first().click();
await page.waitForSelector(".ant-drawer-open", { timeout: 8000 }); await page.waitForSelector(".ant-drawer-open", { timeout: 8000 });
const drawerText = await page.locator(".ant-drawer-title").textContent(); const drawerText = await page.locator(".ant-drawer-title").textContent();
check("邮件详情抽屉打开", Boolean(drawerText && drawerText.includes("端到端"))); check("邮件详情抽屉打开", Boolean(drawerText && drawerText.includes("WmailHub")));
await page.screenshot({ path: `${SHOT_DIR}/09-email-detail.png` }); await page.screenshot({ path: `${SHOT_DIR}/08-email-detail.png` });
await page.keyboard.press("Escape");
// 5. 生成 API Key 流程 // 6. 设置模态窗口:基本设置 + 生成 MCP Key
await page.goto(`${BASE}/keys`, { waitUntil: "domcontentloaded" }); await page.locator("button[aria-label='设置']").click();
await page.waitForSelector("text=生成 Key", { timeout: 8000 }); await page.waitForSelector(".ant-modal:has-text('全局 Webhook')", { timeout: 8000 });
await page.click("button:has-text('生成 Key')"); check("设置模态窗口打开(全局 Webhook / Ingress / 密码)", true);
await page.click(".ant-dropdown-menu-item:has-text('生成 API Key')"); await page.screenshot({ path: `${SHOT_DIR}/09-settings-general.png` });
await page.click(".ant-tabs-tab:has-text('MCP Keys')");
await page.waitForSelector("button:has-text('生成 MCP Key')", { timeout: 8000 });
await page.click("button:has-text('生成 MCP Key')");
await page.waitForSelector(".ant-modal:has-text('仅显示一次')", { timeout: 8000 }); await page.waitForSelector(".ant-modal:has-text('仅显示一次')", { timeout: 8000 });
const keyText = await page.locator(".ant-modal .mono").first().textContent(); const keyText = await page.locator(".ant-modal:has-text('仅显示一次') .mono").first().textContent();
check("生成 API Key 弹窗(wmh_ 32 位)", Boolean(keyText && keyText.startsWith("wmh_") && keyText.trim().length === 32), keyText?.trim()); check("生成 MCP Key 弹窗(wmh_ 32 位)", Boolean(keyText && keyText.trim().startsWith("wmh_") && keyText.trim().length === 32), keyText?.trim());
await page.screenshot({ path: `${SHOT_DIR}/10-key-created.png` }); await page.screenshot({ path: `${SHOT_DIR}/10-mcp-key-created.png` });
await browser.close(); await browser.close();
+137 -86
View File
@@ -1,7 +1,7 @@
/** Webhook 测试:SSRF 校验、投递签名、重试与退避、失败状态、手动测试投递 */ /** Webhook 测试(v1.1.0 三级模型):SSRF 校验、三级优先级、签名/key/自定义头、重试与退避 */
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { ingestEmail } from "../console/src/services/email_ingest.ts"; import { ingestEmail } from "../console/src/services/email_ingest.ts";
import { attemptDelivery, processDueDeliveries } from "../console/src/services/webhook_delivery.ts"; import { attemptDelivery, processDueDeliveries, resolveWebhookConfig } from "../console/src/services/webhook_delivery.ts";
import { hmacSha256Hex } from "../shared/src/index.ts"; import { hmacSha256Hex } from "../shared/src/index.ts";
import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL } from "./helpers.ts"; import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL } from "./helpers.ts";
@@ -21,8 +21,25 @@ function startReceiver(mode: "ok" | "fail500"): { url: string; requests: { heade
return { url: `http://127.0.0.1:${server.port}/hook`, requests, stop: () => server.stop(true) }; return { url: `http://127.0.0.1:${server.port}/hook`, requests, stop: () => server.stop(true) };
} }
describe("Webhook", () => { /** # 函数
test("创建 webhook 时拒绝私网/localhost endpoint(SSRF)", async () => { * 直接给域名/邮箱行写 webhook 配置(key 以空占位,投递 key 传 "" 即可)
*/
async function setScopedWebhook(
t: Awaited<ReturnType<typeof makeTestEnv>>,
table: "mailboxes" | "domains",
id: string,
url: string | null,
headers: Record<string, string> = {},
) {
await t.env.DB.prepare(
`UPDATE ${table} SET webhook_url = ?, webhook_headers = ?, webhook_key_enc = NULL WHERE id = ?`,
)
.bind(url, Object.keys(headers).length ? JSON.stringify(headers) : null, id)
.run();
}
describe("Webhook(邮箱 -> 域名 -> 全局)", () => {
test("保存配置时拒绝私网/localhost endpoint(SSRF)", async () => {
const t = await makeTestEnv(); const t = await makeTestEnv();
// SSRF 防护在"未开启本地联调开关"时生效(生产默认态) // SSRF 防护在"未开启本地联调开关"时生效(生产默认态)
delete (t.env as { WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string }).WMAILHUB_ALLOW_PRIVATE_WEBHOOKS; delete (t.env as { WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string }).WMAILHUB_ALLOW_PRIVATE_WEBHOOKS;
@@ -35,57 +52,54 @@ describe("Webhook", () => {
"http://[::1]/hook", "http://[::1]/hook",
"ftp://example.com/x", "ftp://example.com/x",
]) { ]) {
const resp = await authedFetch(t, cookie, "http://localhost/api/webhooks", { const resp = await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "POST", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "x", endpoint }), body: JSON.stringify({ webhook: { url: endpoint } }),
}); });
expect(resp.status).toBe(400); expect(resp.status).toBe(400);
const resp2 = await authedFetch(t, cookie, `http://localhost/api/mailboxes/${t.mailboxId}/webhook`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: endpoint }),
});
expect(resp2.status).toBe(400);
} }
}); });
test("允许公网 endpoint 创建成功", async () => { test("全局 Webhook 投递:key 走 Authorization,签名与自定义头正确", 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 t = await makeTestEnv();
const recv = startReceiver("ok"); const recv = startReceiver("ok");
const now = new Date().toISOString(); const cookie = await loginCookie(t);
await t.env.DB.prepare( const save = await authedFetch(t, cookie, "http://localhost/api/settings", {
"INSERT INTO webhooks (id, name, endpoint, secret, enabled, created_at, updated_at) VALUES ('whk1', 'w', ?, 'whsec_test', 1, ?, ?)", method: "PUT",
) headers: { "Content-Type": "application/json" },
.bind(recv.url, now, now) body: JSON.stringify({
.run(); webhook: { url: recv.url, headers: { "X-Custom": "wmailhub" }, key: "wmh_global_key_1234567890" },
}),
});
expect(save.status).toBe(200);
const email = await ingestEmail(t.env, { const email = await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL), raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c", mailFrom: "a@b.c",
rcptTo: "hello@example.test", rcptTo: "hello@example.test",
nodeId: t.nodeId, nodeId: t.nodeId,
}); });
const dlv = await t.env.DB.prepare("SELECT id FROM webhook_deliveries").first<{ id: string }>(); const dlv = await t.env.DB.prepare("SELECT id, target_type FROM webhook_deliveries").first<{
expect(dlv).not.toBeNull(); id: string;
target_type: string;
}>();
expect(dlv?.target_type).toBe("global");
const stats = await processDueDeliveries(t.env, undefined, 20, true); const stats = await processDueDeliveries(t.env, undefined, 20, true);
expect(stats.success).toBe(1); 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 req = recv.requests[0];
expect(req.headers.get("Authorization") ?? null).toBe("Bearer wmh_global_key_1234567890");
expect(req.headers.get("X-Custom") ?? null).toBe("wmailhub");
const ts = req.headers.get("X-WmailHub-Timestamp") ?? ""; const ts = req.headers.get("X-WmailHub-Timestamp") ?? "";
const sig = await hmacSha256Hex("whsec_test", String(ts).concat(".", req.body)); const sig = await hmacSha256Hex("wmh_global_key_1234567890", String(ts).concat(".", req.body));
expect(req.headers.get("X-WmailHub-Signature") ?? null).toBe(sig); 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-Event") ?? null).toBe("email.received");
expect(req.headers.get("X-WmailHub-Delivery") ?? null).toBe(dlv?.id ?? null); expect(req.headers.get("X-WmailHub-Delivery") ?? null).toBe(dlv?.id ?? null);
@@ -96,77 +110,114 @@ describe("Webhook", () => {
recv.stop(); recv.stop();
}); });
test("5xx 失败进入重试队列,退避时间递增,超过上限 exhausted", async () => { test("三级优先级:邮箱 > 域名 > 全局,命中即止;逐级回退", async () => {
const t = await makeTestEnv(); const t = await makeTestEnv();
const now = new Date().toISOString(); const globalRecv = startReceiver("ok");
const domainRecv = startReceiver("ok");
const mailboxRecv = startReceiver("ok");
// 三级全配
await setScopedWebhook(t, "domains", t.domainId, domainRecv.url);
await setScopedWebhook(t, "mailboxes", t.mailboxId, mailboxRecv.url);
await t.env.DB.prepare( 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, ?)", "INSERT INTO settings (key, value) VALUES ('global_webhook', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
) )
.bind(now) .bind(JSON.stringify({ url: globalRecv.url, headers: {} }))
.run(); .run();
// msg_gone 不存在(触发 email expired 分支前先看 join):join 不到直接跳过 — 改用真实邮件
await t.env.DB.prepare("DELETE FROM webhook_deliveries WHERE id='d1'").run(); await ingestEmail(t.env, {
// 建真实 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), raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c", mailFrom: "a@b.c",
rcptTo: "hello@example.test", rcptTo: "hello@example.test",
nodeId: t.nodeId, nodeId: t.nodeId,
}); });
await t.env.DB.prepare( await processDueDeliveries(t.env, undefined, 20, true);
"INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, created_at) VALUES ('d2', 'whk_gone', ?, 'pending', 0, ?)", expect(mailboxRecv.requests.length).toBe(1);
) expect(domainRecv.requests.length).toBe(0);
.bind(email.id, now) expect(globalRecv.requests.length).toBe(0);
.run(); const level1 = await t.env.DB.prepare("SELECT target_type FROM webhook_deliveries ORDER BY created_at DESC LIMIT 1").first<{ target_type: string }>();
// 直连 attemptDelivery 到一个 5xx 地址(fetch 会失败) expect(level1?.target_type).toBe("mailbox");
// 清除邮箱级 -> 回退域名级
await setScopedWebhook(t, "mailboxes", t.mailboxId, null);
expect((await resolveWebhookConfig(t.env, t.mailboxId))?.targetType).toBe("domain");
await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
await processDueDeliveries(t.env, undefined, 20, true);
expect(domainRecv.requests.length).toBe(1);
// 再清除域名级 -> 回退全局
await setScopedWebhook(t, "domains", t.domainId, null);
expect((await resolveWebhookConfig(t.env, t.mailboxId))?.targetType).toBe("global");
await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
await processDueDeliveries(t.env, undefined, 20, true);
expect(globalRecv.requests.length).toBe(1);
for (const r of [globalRecv, domainRecv, mailboxRecv]) r.stop();
});
test("三级都未配置:不入队投递", async () => {
const t = await makeTestEnv();
await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
const count = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM webhook_deliveries").first<{ n: number }>();
expect(count?.n).toBe(0);
});
test("连续失败进入重试并最终 exhausted(退避递增)", async () => {
const t = await makeTestEnv();
// 直接连一个不可达地址,验证 attemptDelivery 失败路径
const result = await attemptDelivery( const result = await attemptDelivery(
{ id: "whk_gone", endpoint: "http://127.0.0.1:1/hook", secret: "s" }, { url: "http://127.0.0.1:1/hook", headers: {}, key: "" },
"d2", "d_test",
{ event: "email.received" }, { event: "email.received" },
undefined, undefined,
true, true,
); );
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
// 状态机:连续失败到上限 → exhausted(每轮把 next_retry_at 拉回现在模拟时间流逝) // 状态机:把全局 webhook 指向不可达地址,反复处理直到 exhausted
const cookie = await loginCookie(t);
await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ webhook: { url: "http://127.0.0.1:1/hook" } }),
});
await ingestEmail(t.env, {
raw: new TextEncoder().encode(TEST_MAIL),
mailFrom: "a@b.c",
rcptTo: "hello@example.test",
nodeId: t.nodeId,
});
const { WEBHOOK_MAX_ATTEMPTS } = await import("../shared/src/index.ts"); const { WEBHOOK_MAX_ATTEMPTS } = await import("../shared/src/index.ts");
let attempts = 0; const row0 = await t.env.DB.prepare(
"SELECT id FROM webhook_deliveries WHERE status = 'pending' ORDER BY created_at DESC LIMIT 1",
).first<{ id: string }>();
expect(row0).not.toBeNull();
for (let i = 0; i < WEBHOOK_MAX_ATTEMPTS + 1; i++) { for (let i = 0; i < WEBHOOK_MAX_ATTEMPTS + 1; i++) {
const stats = await processDueDeliveries(t.env, undefined, 20, true); const stats = await processDueDeliveries(t.env, undefined, 20, true);
if (stats.processed === 0) break; if (stats.processed === 0) break;
attempts++; await t.env.DB.prepare("UPDATE webhook_deliveries SET next_retry_at = ? WHERE id = ?")
await t.env.DB.prepare("UPDATE webhook_deliveries SET next_retry_at = ? WHERE id='d2'") .bind(new Date().toISOString(), row0!.id)
.bind(new Date().toISOString())
.run(); .run();
} }
const row = await t.env.DB.prepare("SELECT status, attempt_count FROM webhook_deliveries WHERE id='d2'").first<{ const final = await t.env.DB.prepare("SELECT status, attempt_count FROM webhook_deliveries WHERE id = ?")
status: string; .bind(row0!.id)
attempt_count: number; .first<{ status: string; attempt_count: number }>();
}>(); expect(final?.status).toBe("exhausted");
// 目标不可达(端口 1),每次都失败,最终 exhausted expect(final?.attempt_count).toBe(WEBHOOK_MAX_ATTEMPTS);
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();
}); });
}); });
+13
View File
@@ -0,0 +1,13 @@
// Vite 配置:React(antd)SPA,构建产物输出到 console/public(Console 静态目录)
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
root: "console/frontend",
plugins: [react()],
build: {
outDir: "../public",
emptyOutDir: true,
sourcemap: Boolean(process.env.WMAILHUB_DEV),
},
});