diff --git a/README.md b/README.md index fef351e..55d89fb 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,15 @@ Internet → Cloudflare Email Routing → Email Worker → Node → Console → └→ 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),收到什么转发什么,不做业务判断。 - **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 账号) ```sh @@ -44,18 +49,19 @@ curl -X POST http://127.0.0.1:18789/__email_test \ --data-binary @tests/fixtures/test-mail.eml ``` -`bun run dev` 首次运行会自动:创建本地 Node → enrollment 换取身份(config.json,0600)→ sim 模式 Cloudflare OAuth 自动安装(Email Worker / Email Routing / Domain `example.test` / catch-all Mailbox)→ Node 心跳上线。全程无需复制任何安装命令。 +`bun run dev` 首次运行会自动:「添加账号」→ sim 模式 Cloudflare OAuth 自动创建账号 → 同步账号下全部域名 + catch-all 邮箱 → 逐域名配置 Email Workers → Node enrollment 换取身份(config.json,0600)→ 心跳上线。全程无需复制任何安装命令。 ## 功能 -- **用户登录**:用户名密码(PBKDF2),默认 admin/admin,HMAC 签名会话 cookie。 -- **Node 管理**:Add Node → Cloudflare OAuth(本地 sim / 真实 OAuth)→ 自动安装,步骤可视化,凭据轮换。 +- **用户登录**:用户名密码(PBKDF2),默认 admin/admin,HMAC 签名会话 cookie;密码在「设置」中修改。 +- **账号管理**:「添加账号」直达 Cloudflare OAuth(本地 sim / 真实 OAuth)→ 自动创建账号并同步该账号下全部域名;凭据轮换、Ingress 修改。 +- **域名**:由 OAuth 自动同步(无手工添加);点击域名「设置 Email Workers」= 部署账号级 Email Worker + 开启 Email Routing + catch-all 规则;支持按账号筛选。 - **邮件管线**:MIME 解析(postal-mime)、附件、catch-all/精确匹配、大小限额、malformed 降级入库。 - **TTL**:邮件默认保留 1 天(`expires_at`),Cron 定期清理 R2 + DB + orphan,幂等。 -- **Webhook**:`email.received` 事件推送,HMAC 签名头(`X-WmailHub-Signature`),指数退避重试,SSRF 防护。 +- **Webhook(三级)**:配置落在 邮箱 → 域名 → 全局(设置)三级,收信时取第一个命中者转发 `email.received` 事件;支持自定义请求头与 key(key 默认以 `Authorization: Bearer ` 携带,并用于 `X-WmailHub-Signature` 签名);指数退避重试,SSRF 防护。 - **API v1**:`Authorization: Bearer wmh_...` 访问 `/api/v1/*`。 -- **MCP**:`/api/v1/mcp`(JSON-RPC over HTTP),工具:`list_mailboxes` / `list_emails` / `get_email` / `create_mailbox` / `delete_mailbox` / `list_webhooks`。 -- **Key 管理**:API Key 与 MCP Key 在后端设置,`wmh_` 前缀 + 随机串,总长 32 位;库中只存 SHA-256,明文仅创建时展示一次。 +- **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,明文仅创建时展示一次。 ## Node 协议(docs/04) @@ -77,7 +83,7 @@ WmailHub/ shared/ 共享协议:HMAC 签名 / Key 生成 / 加密 / SSRF 防护 / Email Worker 脚本模板 migrations/ D1 schema tests/ bun test 测试 + UI 冒烟 + fixtures - scripts/ dev 启动 / 前端构建 + scripts/ dev 启动(前端构建走 vite) docs/ 设计文档(00~10) ``` @@ -86,10 +92,11 @@ WmailHub/ 1. `bunx wrangler d1 create wmailhub` → 将 `database_id` 填入 `console/wrangler.jsonc` 2. `bunx wrangler r2 bucket create wmailhub` 3. `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql` -4. `cd console && bunx wrangler deploy`(前端先 `bun run build`) -5. `bunx wrangler secret put SESSION_SECRET --config console/wrangler.jsonc`(≥32 位随机串) -6. Cloudflare Dashboard 创建 OAuth App(client id/secret),配置 `CF_OAUTH_CLIENT_ID/SECRET` vars 与回调 `/api/oauth/cloudflare/callback`;未配置时自动进入 sim 模式(仅供本地) -7. Node 部署在任意能被 Email Worker 访问的服务器:`WMAILHUB_ENROLLMENT_TOKEN=... bun node/src/main.ts`(token 在 Console「添加节点」后展示一次) +4. `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0002_accounts_webhooks.sql` +5. `cd console && bunx wrangler deploy`(前端先 `bun run build`) +6. `bunx wrangler secret put SESSION_SECRET --config console/wrangler.jsonc`(≥32 位随机串) +7. Cloudflare Dashboard 创建 OAuth App(client id/secret),配置 `CF_OAUTH_CLIENT_ID/SECRET` vars 与回调 `/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 自动上传,无需手工部署。 diff --git a/bun.lock b/bun.lock index c21ae4a..9600cf0 100644 --- a/bun.lock +++ b/bun.lock @@ -16,9 +16,11 @@ "devDependencies": { "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.1.1", "bun-types": "^1.2.0", "playwright-core": "^1.63.0", "typescript": "^5.9.0", + "vite": "^8.3.1", }, }, }, @@ -43,6 +45,8 @@ "@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/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=="], + "@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/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=="], + "@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=="], "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=="], + "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=="], "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=="], + "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=="], "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-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=="], + "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=="], "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=="], + "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=="], "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], "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=="], "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/virtual-list/@babel/runtime": ["@babel/runtime@8.0.5", "", {}, "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg=="], diff --git a/console/frontend/index.html b/console/frontend/index.html index 3d11e43..fcb7ebc 100644 --- a/console/frontend/index.html +++ b/console/frontend/index.html @@ -6,11 +6,10 @@ - WmailHub
- + diff --git a/console/frontend/public/favicon.ico b/console/frontend/public/favicon.ico new file mode 100644 index 0000000..f954547 Binary files /dev/null and b/console/frontend/public/favicon.ico differ diff --git a/console/frontend/public/favicon.svg b/console/frontend/public/favicon.svg new file mode 100644 index 0000000..bee98f6 --- /dev/null +++ b/console/frontend/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/console/frontend/public/logo.svg b/console/frontend/public/logo.svg new file mode 100644 index 0000000..f3760cd --- /dev/null +++ b/console/frontend/public/logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/console/frontend/src/App.tsx b/console/frontend/src/App.tsx index 065db1f..707babe 100644 --- a/console/frontend/src/App.tsx +++ b/console/frontend/src/App.tsx @@ -1,45 +1,41 @@ -/** 应用框架:100vh 布局(侧栏 + 顶栏大标题 + 内容区滚动),会话守卫 */ +/** 应用框架:100vh 布局(侧栏 + 顶栏页面标题 + 内容区滚动),会话守卫 + 设置入口 */ import { useEffect, useState } from "react"; 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 { DashboardOutlined, MailOutlined, CloudServerOutlined, GlobalOutlined, - BellOutlined, - KeyOutlined, + SettingOutlined, UserOutlined, LogoutOutlined, } from "@ant-design/icons"; import { health, logout, me } from "./api.ts"; +import { SettingsModal } from "./components/SettingsModal.tsx"; const { Sider, Header, Content } = Layout; /** # 成员变量: 侧栏菜单项 **/ const MENU_ITEMS = [ { key: "/", icon: , label: "概览" }, - { key: "/nodes", icon: , label: "节点 Nodes" }, + { key: "/accounts", icon: , label: "账号 Accounts" }, { key: "/domains", icon: , label: "域名 Domains" }, { key: "/mailboxes", icon: , label: "邮箱 Mailboxes" }, { key: "/emails", icon: , label: "邮件 Emails" }, - { key: "/webhooks", icon: , label: "Webhooks" }, - { key: "/keys", icon: , label: "API / MCP Keys" }, ]; /** # 成员变量: 路由 -> 页面标题/副标题 **/ const PAGE_META: Record = { - "/": { title: "概览", subtitle: "节点、邮件与投递总览" }, - "/nodes": { title: "节点管理", subtitle: "通过 Cloudflare OAuth 自动安装的转发节点" }, - "/domains": { title: "域名管理", subtitle: "接入 Email Routing 的收件域名" }, - "/mailboxes": { title: "邮箱管理", subtitle: "收件地址与保留策略" }, + "/": { title: "概览", subtitle: "账号、域名与邮件总览" }, + "/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号" }, + "/domains": { title: "域名", subtitle: "OAuth 自动同步的收件域名与 Email Workers" }, + "/mailboxes": { title: "邮箱", subtitle: "收件地址、保留策略与邮箱级 Webhook" }, "/emails": { title: "邮件", subtitle: "已接收邮件(默认保留 1 天)" }, - "/webhooks": { title: "Webhooks", subtitle: "邮件事件推送与投递日志" }, - "/keys": { title: "API / MCP Keys", subtitle: "wmh_ 前缀凭据,仅创建时可见" }, }; /** # 函数 - * 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 版本号(package.json 来源) + * 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 设置入口;侧栏品牌带版本号 */ export function App() { const navigate = useNavigate(); @@ -47,6 +43,7 @@ export function App() { const [ready, setReady] = useState(false); const [username, setUsername] = useState(""); const [version, setVersion] = useState(""); + const [settingsOpen, setSettingsOpen] = useState(false); useEffect(() => { void (async () => { @@ -70,6 +67,8 @@ export function App() {
WmailHub WmailHub + {/* 版本号小一号、居上对齐(来源 package.json) */} + {version && v{version}}
-
+
{meta.title}
{meta.subtitle}
- v{version} + + + + +
+ 默认 Node Ingress 地址 + + 「添加账号」创建的账号默认使用的 Node Ingress 地址:即 Email Worker 收到邮件后转发到的 Node 接收入口 + (Node 再签名转发给 Console)。生产环境填 Node 的公网 HTTPS 地址。 + + + + + + + +
+ +
void savePassword()}> + 修改密码 + + + + + + + ({ + validator: (_, v) => + !v || v === getFieldValue("new_password") + ? Promise.resolve() + : Promise.reject(new Error("两次输入不一致")), + }), + ]} + > + + + +
+ + ); +} + +/** # 函数 + * API/MCP Key 管理表格 + */ +function KeysTab({ kind }: { kind: "api" | "mcp" }) { + const { message, modal } = App.useApp(); + const [items, setItems] = useState([]); + 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: ( +
+ + {res.key} + + 请立即保存;关闭后无法再次查看。 +
+ ), + }); + void load(); + } catch (err) { + message.error(err instanceof Error ? err.message : "生成失败"); + } + }; + + return ( +
+
+ setQ(e.target.value)} + /> + +
+ ****{v} }, + { + title: "状态", + dataIndex: "revoked_at", + width: 90, + render: (v: string | null) => (v ? 已撤销 : 有效), + }, + { 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) => ( + + {!row.revoked_at && ( + { + await revokeKey(row.id); + message.success("已撤销"); + void load(); + }} + > + + + )} + + + ), + }, + ]} + /> + + ); +} diff --git a/console/frontend/src/components/WebhookForm.tsx b/console/frontend/src/components/WebhookForm.tsx new file mode 100644 index 0000000..cb01fd4 --- /dev/null +++ b/console/frontend/src/components/WebhookForm.tsx @@ -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 { + if (!s) return {}; + try { + const v = JSON.parse(s); + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; + } 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 = {}; + if (values.headers.trim()) { + try { + headers = JSON.parse(values.headers) as Record; + } 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 ( +
+ + + + + + + 携带,并用于投递签名" + } + > + + + + + {value.url && ( + + )} + + + ); +} diff --git a/console/frontend/src/main.tsx b/console/frontend/src/main.tsx index 6add694..d961c9a 100644 --- a/console/frontend/src/main.tsx +++ b/console/frontend/src/main.tsx @@ -1,4 +1,4 @@ -/** 前端入口:antd v6 + react-router,路由守卫由 App 内 authGate 状态控制 */ +/** 前端入口:antd v6 + react-router(Vite 构建) */ import React from "react"; import { createRoot } from "react-dom/client"; import { ConfigProvider, App as AntdApp } from "antd"; @@ -10,12 +10,10 @@ import { App } from "./App.tsx"; import { LoginPage } from "./pages/Login.tsx"; import { OAuthSimPage } from "./pages/OAuthSim.tsx"; import { DashboardPage } from "./pages/Dashboard.tsx"; -import { NodesPage } from "./pages/Nodes.tsx"; +import { AccountsPage } from "./pages/Accounts.tsx"; import { DomainsPage } from "./pages/Domains.tsx"; import { MailboxesPage } from "./pages/Mailboxes.tsx"; import { EmailsPage } from "./pages/Emails.tsx"; -import { WebhooksPage } from "./pages/Webhooks.tsx"; -import { KeysPage } from "./pages/Keys.tsx"; import { setUnauthorizedHandler } from "./api.ts"; /** # 成员变量: 路由表 **/ @@ -27,12 +25,10 @@ const router = createBrowserRouter([ element: , children: [ { index: true, element: }, - { path: "nodes", element: }, + { path: "accounts", element: }, { path: "domains", element: }, { path: "mailboxes", element: }, { path: "emails", element: }, - { path: "webhooks", element: }, - { path: "keys", element: }, ], }, ]); diff --git a/console/frontend/src/pages/Nodes.tsx b/console/frontend/src/pages/Accounts.tsx similarity index 55% rename from console/frontend/src/pages/Nodes.tsx rename to console/frontend/src/pages/Accounts.tsx index 2ce1f7d..cd9efbc 100644 --- a/console/frontend/src/pages/Nodes.tsx +++ b/console/frontend/src/pages/Accounts.tsx @@ -1,36 +1,33 @@ -/** 节点页:Add Node(OAuth 自动安装)、安装进度、凭据轮换 */ -import { useCallback, useEffect, useState } from "react"; -import { useSearchParams } from "react-router-dom"; +/** 账号页(原节点):添加账号直达 Cloudflare OAuth,授权后自动同步域名;搜索、详情、凭据管理 */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { Button, Card, Drawer, - Form, Input, - Modal, Space, Table, Tag, Timeline, Typography, App, - Alert, } from "antd"; -import { PlusOutlined, ReloadOutlined } from "@ant-design/icons"; +import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"; import { - createNode, - deleteNode, - getNode, - getNodeIngressSecret, - listNodes, + deleteAccount, + getAccount, + getAccountIngressSecret, + listAccounts, oauthMode, oauthStart, - rotateNode, - type NodeDetail, - type NodeRow, + rotateAccount, + updateAccount, + type AccountDetail, + type AccountRow, } from "../api.ts"; -/** # 成员变量: 节点状态 -> Tag 颜色 **/ +/** # 成员变量: 账号状态 -> Tag 颜色 **/ const STATUS_COLOR: Record = { pending_oauth: "orange", provisioning: "processing", @@ -44,29 +41,26 @@ const STEP_LABEL: Record = { oauth: "Cloudflare OAuth 授权", identity: "Node identity 与 credential", discover: "Account / Zone 发现", - email_worker: "Email Worker 创建/更新", - email_routing: "Email Routing 配置", - domains: "Domain 创建", - mailboxes: "Mailbox 创建", + domains: "域名与 catch-all 邮箱同步", }; /** # 函数 - * 节点页组件 + * 账号页组件 */ -export function NodesPage() { +export function AccountsPage() { const { message, modal } = App.useApp(); + const navigate = useNavigate(); const [params] = useSearchParams(); - const [items, setItems] = useState([]); + const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); - const [addOpen, setAddOpen] = useState(false); - const [form] = Form.useForm(); - const [detail, setDetail] = useState(null); + const [q, setQ] = useState(""); + const [detail, setDetail] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const load = useCallback(async () => { setLoading(true); try { - setItems((await listNodes()).items); + setItems((await listAccounts()).items); } finally { setLoading(false); } @@ -79,7 +73,7 @@ export function NodesPage() { if (provisionId) { void (async () => { try { - setDetail(await getNode(provisionId)); + setDetail(await getAccount(provisionId)); setDetailOpen(true); } catch { // ignore @@ -88,43 +82,53 @@ export function NodesPage() { } }, [load, params]); - /** Add Node 提交:创建节点后进入 OAuth */ - const submitAdd = async () => { - const values = await form.validateFields(); - const created = await createNode(values.name, values.ingress_url || undefined); - setAddOpen(false); - form.resetFields(); - message.success("节点已创建,开始 Cloudflare 授权"); - await startOAuth(created.node_id); - }; + const filtered = useMemo( + () => + items.filter((row) => + `${row.name} ${row.status} ${row.cloudflare_account_id ?? ""}` + .toLowerCase() + .includes(q.trim().toLowerCase()), + ), + [items, q], + ); - /** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页 */ - const startOAuth = async (nodeId: string) => { - const mode = (await oauthMode()).mode; - const { url } = await oauthStart(nodeId); - if (mode === "cloudflare") { - window.location.href = url; - } else { - window.location.href = url; // /oauth/sim/authorize?state=... + /** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页。 + * accountId 缺省 = 服务端自动创建账号。 */ + const startOAuth = async (accountId?: string) => { + try { + const mode = (await oauthMode()).mode; + const { url } = await oauthStart(accountId); + if (mode === "cloudflare") window.location.href = url; + else navigate(url); // /oauth/sim/authorize?state=... + } catch (err) { + message.error(err instanceof Error ? err.message : "发起授权失败"); } }; const openDetail = async (id: string) => { - setDetail(await getNode(id)); + setDetail(await getAccount(id)); setDetailOpen(true); }; return (
- - 点击「添加节点」后完成 Cloudflare OAuth,系统自动安装 Email Worker / Email Routing / Domain / Mailbox, - 全程无需在服务器执行任何安装命令。 - +
+ } + placeholder="搜索名称 / 状态 / Cloudflare 账户" + style={{ width: 280 }} + onChange={(e) => setQ(e.target.value)} + /> + + 点击「添加账号」完成 Cloudflare OAuth,自动同步账号下全部域名;在域名列表逐域名开启 Email Workers。 + +
@@ -133,7 +137,7 @@ export function NodesPage() {
({ onClick: () => void openDetail(row.id), style: { cursor: "pointer" } })} columns={[ @@ -141,21 +145,22 @@ export function NodesPage() { { title: "状态", dataIndex: "status", - width: 120, + width: 130, render: (v: string) => {v}, }, - { 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) => {v} }, { title: "版本", dataIndex: "version", width: 90, render: (v: string | null) => v ?? "-" }, { title: "最近心跳", dataIndex: "last_seen_at", - width: 190, + width: 170, render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"), }, { title: "操作", width: 150, - render: (_: unknown, row: NodeRow) => ( + render: (_: unknown, row: AccountRow) => ( + - enrollment 有效期至 {detail.node.enrollment_expires_at ?? "-"} + enrollment 有效期至 {detail.account.enrollment_expires_at ?? "-"} - {showSecret && null} ); } diff --git a/console/frontend/src/pages/Dashboard.tsx b/console/frontend/src/pages/Dashboard.tsx index 484f305..c2cb53b 100644 --- a/console/frontend/src/pages/Dashboard.tsx +++ b/console/frontend/src/pages/Dashboard.tsx @@ -49,12 +49,12 @@ export function DashboardPage() { - + - + @@ -69,7 +69,7 @@ export function DashboardPage() { - + diff --git a/console/frontend/src/pages/Domains.tsx b/console/frontend/src/pages/Domains.tsx index e71422f..729e54f 100644 --- a/console/frontend/src/pages/Domains.tsx +++ b/console/frontend/src/pages/Domains.tsx @@ -1,8 +1,31 @@ -/** 域名页 */ -import { useCallback, useEffect, useState } from "react"; -import { Button, Card, Form, Input, Modal, Space, Table, Tag, App } from "antd"; -import { PlusOutlined, ReloadOutlined } from "@ant-design/icons"; -import { createDomain, deleteDomain, listDomains, type DomainRow } from "../api.ts"; +/** + * 域名页(v1.1.0):域名由 Cloudflare OAuth 自动同步(无手工添加); + * 点击域名配置 Email Workers(Worker + Email Routing + catch-all)与域名级 Webhook; + * 支持搜索与按账号筛选。 + */ +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 已配置 Email Workers; + if (row.status === "error") + return ( + + error + + ); + return pending; +} /** # 函数 * 域名页组件 @@ -10,14 +33,18 @@ import { createDomain, deleteDomain, listDomains, type DomainRow } from "../api. export function DomainsPage() { const { message, modal } = App.useApp(); const [items, setItems] = useState([]); + const [accountOptions, setAccountOptions] = useState<{ id: string; name: string }[]>([]); const [loading, setLoading] = useState(false); - const [open, setOpen] = useState(false); - const [form] = Form.useForm(); + const [q, setQ] = useState(""); + const [accountId, setAccountId] = useState(); + const [editing, setEditing] = useState(null); const load = useCallback(async () => { setLoading(true); try { - setItems((await listDomains()).items); + const [d, a] = await Promise.all([listDomains(), listDomainAccounts()]); + setItems(d.items); + setAccountOptions(a.items); } finally { setLoading(false); } @@ -27,90 +54,189 @@ export function DomainsPage() { void 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 (
- OAuth 安装流程会自动创建域名;此处也可手工登记。 - - - +
+ } + placeholder="搜索域名" + style={{ width: 240 }} + onChange={(e) => setQ(e.target.value)} + /> +
({ onClick: () => setEditing(row), style: { cursor: "pointer" } })} columns={[ - { title: "域名", dataIndex: "name" }, + { title: "域名", dataIndex: "name", render: (v: string) => {v} }, + { title: "账号", dataIndex: "node_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" }, + { title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => }, { - title: "状态", - dataIndex: "status", - width: 100, - render: (v: string) => {v}, + title: "Email Worker", + dataIndex: "worker_id", + width: 210, + ellipsis: true, + render: (v: string | null) => (v ? {v} : 未配置), }, - { title: "节点", dataIndex: "node_name", width: 160, render: (v: string | null) => v ?? "-" }, - { title: "Worker", dataIndex: "worker_id", width: 220, render: (v: string | null) => v ?? "-" }, - { title: "邮箱数", dataIndex: "mailbox_count", width: 90 }, + { + title: "Webhook", + width: 95, + render: (_: unknown, row: DomainRow) => + row.webhook_url ? 已配置 : -, + }, + { title: "邮箱数", dataIndex: "mailbox_count", width: 80 }, { title: "操作", - width: 90, + width: 200, render: (_: unknown, row: DomainRow) => ( - + e.stopPropagation()}> + {row.status !== "active" ? ( + + ) : ( + + )} + + ), }, ]} /> - setOpen(false)} - onOk={async () => { - const values = await form.validateFields(); - try { - await createDomain(values.name); - message.success("已添加"); - setOpen(false); - form.resetFields(); - void load(); - } catch (err) { - message.error(err instanceof Error ? err.message : "添加失败"); - } - }} - okText="添加" - cancelText="取消" - destroyOnHidden + + {editing.name} + + {editing.node_name ?? "-"} + + + ) : ( + "域名" + ) + } + size="large" + open={Boolean(editing)} + onClose={() => setEditing(null)} > -
- - - - -
+ {editing && ( + + void runSetup(editing)}> + 设置 Email Workers + + ) + } + > + + + + 部署账号级 Email Worker(接收 Cloudflare 邮件事件),开启该域名的 Email Routing 并把 catch-all + 规则指向 Worker;邮件经由 Node 转发回 Console。 + + {editing.status === "active" && editing.worker_id && ( + + Worker:{editing.worker_id} · Zone:{editing.zone_id} + + )} + {editing.status === "error" && editing.setup_error && ( + {editing.setup_error} + )} + + + + + + 该域名下邮件未命中邮箱级 Webhook 时使用;再未配置则回退全局 Webhook(设置中配置)。 + + { + const fresh = (await listDomains()).items.find((d) => d.id === editing.id) ?? null; + setEditing(fresh); + void load(); + }} + /> + + + )} + ); } diff --git a/console/frontend/src/pages/Keys.tsx b/console/frontend/src/pages/Keys.tsx deleted file mode 100644 index e29d180..0000000 --- a/console/frontend/src/pages/Keys.tsx +++ /dev/null @@ -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([]); - const [loading, setLoading] = useState(false); - - const load = useCallback(async () => { - setLoading(true); - try { - setItems((await listKeys()).items); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - return ( -
-
- - API Key 调用 /api/v1/*;MCP Key 调用 /api/v1/mcp(JSON-RPC)。均以 Authorization: Bearer wmh_... - 传递;服务端只存 SHA-256,明文仅创建时展示一次。Key 总长 32 位(wmh_ + 28 位随机)。 - - - - -
- -
- -
{v.toUpperCase()}, - }, - { title: "名称", dataIndex: "name" }, - { - title: "Key 尾缀", - dataIndex: "key_last4", - width: 110, - render: (v: string) => ( - - wmh_****{v} - - ), - }, - { - 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 ? 已撤销 : 有效, - }, - { - title: "操作", - width: 160, - render: (_: unknown, row: KeyRow) => ( - - {!row.revoked_at && ( - - )} - - - ), - }, - ]} - /> - - - ); - - /** 生成 key 并展示一次 */ - async function genKey(kind: "api" | "mcp") { - try { - const res = await createKey(kind); - modal.success({ - title: `${kind.toUpperCase()} Key 已创建(仅显示一次)`, - width: 560, - content: ( -
- - 请立即保存,关闭后无法再次查看。Key 格式:wmh_ 前缀 + 28 位随机串,总长 32。 - - - {res.key} - -
- ), - }); - void load(); - } catch (err) { - message.error(err instanceof Error ? err.message : "生成失败"); - } - } -} diff --git a/console/frontend/src/pages/Mailboxes.tsx b/console/frontend/src/pages/Mailboxes.tsx index 6f7ce32..5061d96 100644 --- a/console/frontend/src/pages/Mailboxes.tsx +++ b/console/frontend/src/pages/Mailboxes.tsx @@ -1,8 +1,17 @@ -/** 邮箱页:创建/停用/删除/保留策略 */ -import { useCallback, useEffect, useState } from "react"; +/** 邮箱页:创建/停用/删除/保留策略/邮箱级 Webhook;支持搜索 */ +import { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App } from "antd"; -import { PlusOutlined, ReloadOutlined } from "@ant-design/icons"; -import { createMailbox, deleteMailbox, listDomains, listMailboxes, patchMailbox, type DomainRow, type MailboxRow } from "../api.ts"; +import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"; +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([]); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [webhookTarget, setWebhookTarget] = useState(null); const [form] = Form.useForm(); const load = useCallback(async () => { @@ -30,6 +41,14 @@ export function MailboxesPage() { void 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) => { await patchMailbox(row.id, { status: row.status === "active" ? "disabled" : "active" }); void load(); @@ -38,7 +57,16 @@ export function MailboxesPage() { return (
- catch-all 邮箱(*@domain)会接收该域名下所有未精确匹配的邮件。 +
+ } + placeholder="搜索地址 / 域名" + style={{ width: 260 }} + onChange={(e) => setQ(e.target.value)} + /> + catch-all 邮箱(*@domain)会接收该域名下所有未精确匹配的邮件。 +
), }, - { title: "域名", dataIndex: "domain_name", width: 180 }, + { title: "域名", dataIndex: "domain_name", width: 170, ellipsis: true }, { title: "状态", dataIndex: "status", - width: 100, + width: 95, render: (v: string) => {v}, }, + { + title: "Webhook", + width: 90, + render: (_: unknown, row: MailboxRow) => + row.webhook_url ? 已配置 : -, + }, { title: "保留(秒)", dataIndex: "retention_seconds", - width: 110, + width: 105, render: (v: number | null) => v ?? 默认 86400, }, - { title: "邮件数", dataIndex: "email_count", width: 90 }, + { title: "邮件数", dataIndex: "email_count", width: 85 }, { title: "启用", - width: 80, + width: 75, render: (_: unknown, row: MailboxRow) => ( void toggleStatus(row)} /> ), }, { title: "操作", - width: 90, + width: 150, render: (_: unknown, row: MailboxRow) => ( - + + + + ), }, ]} @@ -152,6 +191,35 @@ export function MailboxesPage() { + + setWebhookTarget(null)} + footer={null} + width={560} + destroyOnHidden + > +

+ 该邮箱收到邮件时优先使用此 Webhook;未配置则回退域名级,再回退全局(设置中配置)。 +

+ {webhookTarget && ( + { + const fresh = (await listMailboxes()).items.find((m) => m.id === webhookTarget.id) ?? null; + setWebhookTarget(fresh); + void load(); + }} + /> + )} +
); } diff --git a/console/frontend/src/pages/OAuthSim.tsx b/console/frontend/src/pages/OAuthSim.tsx index 35663ac..97bd196 100644 --- a/console/frontend/src/pages/OAuthSim.tsx +++ b/console/frontend/src/pages/OAuthSim.tsx @@ -20,14 +20,14 @@ export function OAuthSimPage() { const res = await oauthSimApprove(state); if (res.ok) { message.success("Cloudflare 授权与自动安装完成"); - navigate(`/nodes?provision=${encodeURIComponent(res.node_id)}`, { replace: true }); + navigate(`/accounts?provision=${encodeURIComponent(res.node_id)}`, { replace: true }); } else { message.error(res.error ?? "安装失败"); - navigate("/nodes", { replace: true }); + navigate("/accounts", { replace: true }); } } catch (err) { message.error(err instanceof Error ? err.message : "授权失败"); - navigate("/nodes", { replace: true }); + navigate("/accounts", { replace: true }); } finally { setBusy(false); } @@ -35,7 +35,7 @@ export function OAuthSimPage() { // state 缺失直接回节点页 useEffect(() => { - if (!state) navigate("/nodes", { replace: true }); + if (!state) navigate("/accounts", { replace: true }); }, [state, navigate]); return ( @@ -44,7 +44,7 @@ export function OAuthSimPage() {

sim 模式下,该页面模拟 Cloudflare OAuth 授权同意流程。

真实部署时,这里会跳转到 dash.cloudflare.com 的授权页面。

- + diff --git a/console/frontend/src/pages/Webhooks.tsx b/console/frontend/src/pages/Webhooks.tsx deleted file mode 100644 index 0a057c2..0000000 --- a/console/frontend/src/pages/Webhooks.tsx +++ /dev/null @@ -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 = { - pending: "processing", - success: "success", - failed: "warning", - exhausted: "error", -}; - -/** # 函数 - * Webhooks 页组件 - */ -export function WebhooksPage() { - const { message, modal } = App.useApp(); - const [items, setItems] = useState([]); - 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: ( -
- secret 仅此一次显示,请立即保存。 - - {secret} - -
- ), - }); - }; - - return ( -
-
- - {"邮件入库后推送 email.received 事件;签名头 X-WmailHub-Signature = HMAC-SHA256(secret, `${timestamp}.${body}`)。"} - - - - -
- -
( - - {row.success_count}/{row.delivery_count} - - ), - }, - { - title: "启用", - width: 80, - render: (_: unknown, row: WebhookRow) => ( - { - await patchWebhook(row.id, { enabled: v }); - void load(); - }} - /> - ), - }, - { - title: "操作", - width: 280, - render: (_: unknown, row: WebhookRow) => ( - - - - - - - ), - }, - ]} - /> - - - 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 : "创建失败"); - } - }} - > -
- - - - - /^https?:\/\//.test(v ?? "") ? Promise.resolve() : Promise.reject(new Error("必须为 http(s) URL")), - }, - ]} - extra="禁止 localhost / 私网 / 内网地址(SSRF 防护)" - > - - - -
- - setDeliveries(null)} - > -
{v}, - }, - { title: "尝试", dataIndex: "attempt_count", width: 60 }, - { - title: "错误", - dataIndex: "last_error", - ellipsis: true, - render: (v: string | null) => v ?? "-", - }, - { - title: "操作", - width: 90, - render: (_: unknown, row: DeliveryRow) => ( - - ), - }, - ]} - /> - - - ); -} diff --git a/console/frontend/src/styles.css b/console/frontend/src/styles.css index ab810bd..89a5229 100644 --- a/console/frontend/src/styles.css +++ b/console/frontend/src/styles.css @@ -39,6 +39,20 @@ body, 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 { display: flex; align-items: center; @@ -47,15 +61,27 @@ body, border-bottom: 1px solid rgba(5, 5, 5, 0.06); } +/* 标题/副标题紧凑排列:行高收紧、不换行、溢出省略 */ +.header-meta { + min-width: 0; + line-height: 0; +} + .app-header .title { - font-size: 20px; + font-size: 17px; font-weight: 650; + line-height: 22px; + white-space: nowrap; } .app-header .subtitle { - font-size: 12.5px; + font-size: 12px; + line-height: 16px; color: rgba(0, 0, 0, 0.45); - margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 56vw; } .app-content { diff --git a/console/src/app.ts b/console/src/app.ts index 90d274e..b5853bb 100644 --- a/console/src/app.ts +++ b/console/src/app.ts @@ -4,9 +4,9 @@ * 路由分区: * /api/health 健康检查 * /api/auth/* 用户登录/登出/会话(cookie) - * /api/nodes|domains|mailboxes|emails 管理资源 CRUD(会话保护) - * /api/webhooks/* Webhook 管理(会话保护) - * /api/keys/* API/MCP Key 管理(会话保护) + * /api/accounts|domains|mailboxes|emails 管理资源 CRUD(会话保护) + * /api/settings/* 设置:全局 Webhook / 默认 Ingress / 改密码(会话保护) + * /api/keys/* API/MCP Key 管理(会话保护;设置模态窗口内使用) * /api/oauth/* Cloudflare OAuth(会话保护) * /api/admin/* 版本/统计/Cron(会话保护) * /api/internal/* Node 内部协议(HMAC 签名) @@ -22,7 +22,7 @@ import { csrfOriginCheck } from "./middleware.ts"; import { authRoutes } from "./auth.ts"; import { internalRoutes } from "./routes/internal.ts"; import { resourceRoutes } from "./routes/resources.ts"; -import { webhookRoutes } from "./routes/webhooks.ts"; +import { settingsRoutes } from "./routes/settings.ts"; import { keyRoutes } from "./routes/keys.ts"; import { oauthRoutes } from "./routes/oauth.ts"; import { apiV1Routes } from "./routes/apiv1.ts"; @@ -54,7 +54,7 @@ export function createApp( app.route("/api/auth", authRoutes()); app.route("/api/internal", internalRoutes()); app.route("/api/oauth", oauthRoutes()); - app.route("/api/webhooks", webhookRoutes()); + app.route("/api/settings", settingsRoutes()); app.route("/api/keys", keyRoutes()); app.route("/api/admin", adminRoutes()); app.route("/api/v1/mcp", mcpRoutes()); diff --git a/console/src/db.ts b/console/src/db.ts index 45794cf..8aacff0 100644 --- a/console/src/db.ts +++ b/console/src/db.ts @@ -104,3 +104,33 @@ export async function decryptSecret(env: Env, blob: string): Promise { export function keyHash(key: string): Promise { return sha256Hex(key); } + +/** # 函数 + * OAuth 连接 token 的加密密钥(沿用历史派生方式,保持既有密文可解) + */ +async function getConnectionKey(env: Env): Promise { + return deriveAesKey((await getSetting(env, "app_secret")) ?? "wmailhub"); +} + +/** # 函数 + * 加密 Cloudflare OAuth access token 入库 + */ +export async function encryptConnectionToken(env: Env, token: string): Promise { + return aesEncrypt(await getConnectionKey(env), token); +} + +/** # 函数 + * 读取最近一条 OAuth 连接的 access token 明文(供 Cloudflare API 调用) + * 返回值: 无连接或解密失败返回 null + */ +export async function decryptConnectionToken(env: Env): Promise { + 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; + } +} diff --git a/console/src/local/main.ts b/console/src/local/main.ts index e6d2867..1f91ec4 100644 --- a/console/src/local/main.ts +++ b/console/src/local/main.ts @@ -31,10 +31,15 @@ const db = new Database(path.join(DATA_DIR, "wmailhub.local.sqlite"), { create: db.run("PRAGMA journal_mode = WAL;"); const migrationsDir = path.join(ROOT, "..", "migrations"); 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()) { 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(); db.exec(sql); + db.query("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run(file, new Date().toISOString()); console.log(`[console] migration applied: ${file}`); } } diff --git a/console/src/routes/admin.ts b/console/src/routes/admin.ts index 4681052..cca9f85 100644 --- a/console/src/routes/admin.ts +++ b/console/src/routes/admin.ts @@ -28,13 +28,16 @@ export function adminRoutes(): App { (await c.env.DB.prepare(sql).first<{ n: number }>())?.n ?? 0; const now = nowIso(); const stats = { - nodes_online: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status = 'online'`), - nodes_total: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'`), + accounts_online: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status = 'online'`), + accounts_total: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'`), 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"), emails: await count(`SELECT COUNT(*) AS n FROM emails WHERE expires_at > '${now}'`), emails_expired: await count(`SELECT COUNT(*) AS n FROM emails WHERE expires_at <= '${now}'`), - webhooks: await count("SELECT COUNT(*) AS n FROM webhooks"), + global_webhook: await count( + `SELECT COUNT(*) AS n FROM settings WHERE key = 'global_webhook' AND value != '{"url":"","headers":{}}' AND value != ''`, + ), deliveries_pending: await count( `SELECT COUNT(*) AS n FROM webhook_deliveries WHERE status IN ('pending','failed')`, ), diff --git a/console/src/routes/apiv1.ts b/console/src/routes/apiv1.ts index 6611db2..0bc79af 100644 --- a/console/src/routes/apiv1.ts +++ b/console/src/routes/apiv1.ts @@ -111,13 +111,5 @@ export function apiV1Routes(): App { 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; } diff --git a/console/src/routes/mcp.ts b/console/src/routes/mcp.ts index 62a0e51..5695459 100644 --- a/console/src/routes/mcp.ts +++ b/console/src/routes/mcp.ts @@ -1,7 +1,7 @@ /** * /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);邮件强制检查未过期。 * 实现说明:POST JSON-RPC -> application/json 响应(Streamable HTTP 允许服务器 * 返回普通 JSON);GET 返回 405(无 SSE 流)。 @@ -72,11 +72,6 @@ const TOOLS = [ additionalProperties: false, }, }, - { - name: "list_webhooks", - description: "列出 Webhook(不含 secret)", - inputSchema: { type: "object", properties: {}, additionalProperties: false }, - }, ] as const; /** # 函数 @@ -239,12 +234,6 @@ async function dispatchTool(env: Env, name: string, args: Record; @@ -23,6 +23,9 @@ type App = Hono<{ Bindings: Env; Variables: AppVars }>; /** # 成员变量: OAuth state 有效期(秒) **/ const STATE_TTL_SECONDS = 300; +/** # 成员变量: 自动创建账号时的默认 Node Ingress 地址(可在设置中修改) **/ +export const DEFAULT_INGRESS_URL = "http://127.0.0.1:18788/ingress/email"; + /** # 函数 * 是否使用真实 Cloudflare OAuth */ @@ -45,13 +48,24 @@ export function oauthRoutes(): App { }); }); - /** 开始授权:创建 state 并返回跳转 URL */ + /** 开始授权:node_id 缺省时自动创建账号;创建 state 并返回跳转 URL */ app.get("/cloudflare/start", async (c) => { - const nodeId = new URL(c.req.url).searchParams.get("node_id") ?? ""; - const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status = 'pending_oauth' OR id = ?") - .bind(nodeId, nodeId) - .first<{ id: string }>(); - if (!node) return c.json({ error: "node not found" }, 404); + let nodeId = new URL(c.req.url).searchParams.get("node_id") ?? ""; + if (nodeId) { + const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'") + .bind(nodeId) + .first<{ id: string }>(); + 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(); await c.env.DB.prepare( @@ -92,7 +106,7 @@ export function oauthRoutes(): App { // 加密入库(不进前端/日志) await saveConnection(c.env, token.accessToken, token.expiresIn); const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken }); - const target = `/nodes?provision=${encodeURIComponent(verdict.nodeId)}&ok=${result.ok ? 1 : 0}`; + const target = `/accounts?provision=${encodeURIComponent(verdict.nodeId)}&ok=${result.ok ? 1 : 0}`; return c.redirect(target, 302); } catch (err) { return c.text(`OAuth provisioning failed: ${err instanceof Error ? err.message : String(err)}`, 500); @@ -143,8 +157,7 @@ async function consumeState( * OAuth access token 加密入库 */ async function saveConnection(env: Env, accessToken: string, expiresIn: number): Promise { - const key = await deriveAesKey(await getSetting(env, "app_secret") ?? "wmailhub"); - const encrypted = await aesEncrypt(key, accessToken); + const encrypted = await encryptConnectionToken(env, accessToken); await env.DB.prepare( `INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at) VALUES (?, 'cloudflare', NULL, ?, NULL, ?, ?, ?)`, diff --git a/console/src/routes/resources.ts b/console/src/routes/resources.ts index 2cb9b64..d28d9a5 100644 --- a/console/src/routes/resources.ts +++ b/console/src/routes/resources.ts @@ -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 type { Env, AppVars } from "../env.ts"; import { requireSession } from "../middleware.ts"; -import { createNode, emailWorkerNameFor, loadSteps } from "../services/provision.ts"; -import { upsertMailbox, upsertDomain } from "../services/provision.ts"; +import { loadSteps, setupDomainEmail, upsertMailbox } from "../services/provision.ts"; import { decryptSecret } from "../db.ts"; -import { ids } 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"; type App = Hono<{ Bindings: Env; Variables: AppVars }>; /** # 函数 - * 装配 Node / Domain / Mailbox / Email 管理路由 + * 装配 Account / Domain / Mailbox / Email 管理路由 */ export function resourceRoutes(): App { const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); app.use("*", requireSession); - installNodeRoutes(app); + installAccountRoutes(app); installDomainRoutes(app); installMailboxRoutes(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) { - /** Node 列表(不含任何 secret) */ - app.get("/nodes", async (c) => { +async function parseWebhookBody( + c: { env: Env; req: { raw: Request } }, +): Promise<{ error: string } | { url: string; headers: Record; key: string | undefined }> { + const body = await readJson<{ url?: string; headers?: unknown; key?: string }>(c.req.raw); + const url = (body.url ?? "").trim(); + let headers: Record; + 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 rows = await c.env.DB.prepare( `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 }); }); - /** Add Node:创建节点,返回 enrollment token(仅此一次展示)与 OAuth 授权入口信息 */ - app.post("/nodes", async (c) => { - const body = await readJson<{ name?: string; ingress_url?: string }>(c.req.raw); - const name = (body.name ?? "").trim(); - if (!name) return c.json({ error: "name required" }, 400); - const ingressUrl = (body.ingress_url ?? "http://127.0.0.1:8788/ingress/email").trim(); - try { - new URL(ingressUrl); - } catch { - return c.json({ error: "invalid ingress_url" }, 400); - } - const created = await createNode(c.env, { name, ingressUrl }); - return c.json({ - ok: true, - node_id: created.nodeId, - enrollment_token: created.enrollmentToken, - ingress_secret: created.ingressSecret, - worker_name: emailWorkerNameFor(created.nodeId), - }, 201); - }); - - /** Node 详情:含安装步骤状态与 OAuth 连接状态 */ - app.get("/nodes/:id", async (c) => { + /** 账号详情:含安装步骤状态与 OAuth 连接状态 */ + app.get("/accounts/:id", async (c) => { 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, ingress_url, enrollment_expires_at, created_at, updated_at FROM nodes WHERE id = ?`, ) .bind(id) .first(); - if (!node) return c.json({ error: "not found" }, 404); + if (!account) return c.json({ error: "not found" }, 404); const steps = await loadSteps(c.env, id); const oauth = await c.env.DB.prepare( "SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC LIMIT 1", @@ -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 = ?") .bind(id) .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) */ - app.delete("/nodes/:id", async (c) => { + /** 更新账号:名称 / Node Ingress 地址(Email Worker 回源地址) */ + 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"); await c.env.DB.prepare( "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 轮换入口) */ - app.post("/nodes/:id/rotate", async (c) => { + app.post("/accounts/:id/rotate", async (c) => { const id = c.req.param("id"); - const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'") + const account = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'") .bind(id) .first(); - if (!node) return c.json({ error: "not found" }, 404); + if (!account) return c.json({ error: "not found" }, 404); const token = "wenr_" + crypto.randomUUID().replace(/-/g, ""); await c.env.DB.prepare( "UPDATE nodes SET enrollment_token = ?, enrollment_expires_at = ?, updated_at = ? WHERE id = ?", @@ -120,24 +152,26 @@ function installNodeRoutes(app: App) { }); /** 本地/运维用途:查看 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 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) .first<{ ingress_secret_enc: string }>(); - if (!node) return c.json({ error: "not found" }, 404); - return c.json({ ingress_secret: await decryptSecret(c.env, node.ingress_secret_enc) }); + if (!account) return c.json({ error: "not found" }, 404); + return c.json({ ingress_secret: await decryptSecret(c.env, account.ingress_secret_enc) }); }); } /** # 函数 - * Domain CRUD + * Domain 查询 / Email Workers 配置 / Webhook 配置 */ function installDomainRoutes(app: App) { app.get("/domains", async (c) => { const { limit, offset } = pageParams(new URL(c.req.url)); const rows = await c.env.DB.prepare( - `SELECT d.*, n.name AS node_name, + `SELECT 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 FROM domains d LEFT JOIN nodes n ON n.id = d.node_id 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 }); }); - /** 手工添加 Domain(供无 OAuth 场景;OAuth 安装自动创建) */ - app.post("/domains", async (c) => { - const body = await readJson<{ name?: string; node_id?: string; cloudflare_account_id?: string }>(c.req.raw); - const name = (body.name ?? "").trim().toLowerCase(); - if (!name || !name.includes(".")) return c.json({ error: "invalid domain name" }, 400); - const id = await upsertDomain(c.env, { - cloudflareAccountId: body.cloudflare_account_id ?? "manual", - nodeId: body.node_id ?? null as unknown as string, - name, - zoneId: "manual", - workerId: null as unknown as string, - }); - return c.json({ ok: true, id }, 201); + /** 域名可用的账号(供筛选) */ + app.get("/domains/filters/accounts", async (c) => { + const rows = await c.env.DB.prepare( + "SELECT DISTINCT n.id, n.name FROM nodes n JOIN domains d ON d.node_id = n.id ORDER BY n.name", + ).all(); + return c.json({ items: rows.results }); + }); + + /** 为域名配置 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) => { @@ -175,7 +225,7 @@ function installDomainRoutes(app: App) { } /** # 函数 - * Mailbox CRUD + * Mailbox CRUD 与邮箱级 Webhook 配置 */ function installMailboxRoutes(app: App) { 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 where = domainId ? "WHERE m.domain_id = ?" : ""; 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 FROM mailboxes m JOIN domains d ON d.id = m.domain_id ${where} ORDER BY m.created_at DESC LIMIT ? OFFSET ?`, @@ -239,6 +291,23 @@ function installMailboxRoutes(app: App) { 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) => { const id = c.req.param("id"); 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 }); }); } - -/** # 函数 - * webhook endpoint 的基本格式校验(创建时调用) - */ -export function validateEndpoint(endpoint: string): string | null { - try { - new URL(endpoint); - } catch { - return "invalid url"; - } - return checkWebhookUrl(endpoint); -} diff --git a/console/src/routes/settings.ts b/console/src/routes/settings.ts new file mode 100644 index 0000000..100634d --- /dev/null +++ b/console/src/routes/settings.ts @@ -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 = { url: "", headers: {}, has_key: false, key_last4: "" }; + if (raw) { + try { + const cfg = JSON.parse(raw) as { url?: string; headers?: Record }; + 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; + 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; +} diff --git a/console/src/routes/webhooks.ts b/console/src/routes/webhooks.ts deleted file mode 100644 index fa22913..0000000 --- a/console/src/routes/webhooks.ts +++ /dev/null @@ -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; -} diff --git a/console/src/services/cfapi.ts b/console/src/services/cfapi.ts index e92c130..8fe7228 100644 --- a/console/src/services/cfapi.ts +++ b/console/src/services/cfapi.ts @@ -118,8 +118,18 @@ export class RealCFApi implements CFApi { return this.api(token, "/accounts"); } - listZones(token: string, accountId: string) { - return this.api(token, `/zones?account.id=${encodeURIComponent(accountId)}&per_page=50`); + async listZones(token: string, accountId: string) { + // 分页拉全账号下的 Zone(每页 50,最多 20 页) + const out: CFZone[] = []; + for (let page = 1; page <= 20; page++) { + const rows = await this.api( + token, + `/zones?account.id=${encodeURIComponent(accountId)}&per_page=50&page=${page}`, + ); + out.push(...rows); + if (rows.length < 50) break; + } + return out; } async putEmailWorker(opts: { @@ -199,13 +209,11 @@ export class SimCFApi implements CFApi { } async listZones(_token: string, accountId: string): Promise { + // sim 返回多个演示域名,覆盖「OAuth 后自动同步全部域名」的展示 const zones = [ - { - id: "sim-zone-example.test", - name: "example.test", - account_id: accountId, - status: "active", - }, + { id: "sim-zone-example.test", name: "example.test", account_id: accountId, status: "active" }, + { id: "sim-zone-demo.test", name: "demo.test", account_id: accountId, status: "active" }, + { id: "sim-zone-mailbox.dev", name: "mailbox.dev", account_id: accountId, status: "active" }, ]; for (const z of zones) await this.record("zone", z.id, z); return zones; diff --git a/console/src/services/email_ingest.ts b/console/src/services/email_ingest.ts index 69a4a67..d445a64 100644 --- a/console/src/services/email_ingest.ts +++ b/console/src/services/email_ingest.ts @@ -18,6 +18,7 @@ import { MAX_ATTACHMENT_BYTES, } from "../../../shared/src/index.ts"; import { nowIso } from "../util.ts"; +import { enqueueDeliveryForEmail } from "./webhook_delivery.ts"; /** # 成员变量: postal-mime 解析出的结构(仅本文件用到的字段) **/ interface ParsedMail { @@ -191,15 +192,8 @@ export async function ingestEmail(env: Env, input: IngestInput): Promise域名->全局 解析;未配置则不入队;投递失败不影响入库) + await enqueueDeliveryForEmail(env, emailId, mailboxId); return { id: emailId, @@ -245,37 +239,3 @@ export async function resolveMailbox(env: Env, addresses: string[]): Promise { - 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 { - 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(); - } -} diff --git a/console/src/services/provision.ts b/console/src/services/provision.ts index f74dad0..8bdb167 100644 --- a/console/src/services/provision.ts +++ b/console/src/services/provision.ts @@ -1,28 +1,26 @@ /** - * Node 自动安装管线(docs/02 流程): - * Add Node(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 -> - * Account/Zone 发现 -> 创建/更新 Email Worker -> 配置 Email Routing -> - * 创建 Domain/Mailbox -> Node Ready。 + * 账号(Node)自动安装管线(v1.1.0): + * 添加账号(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 -> + * Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱。 * - * 所有步骤幂等,重复执行不产生重复资源;每步状态写入 nodes.provision_steps, - * 供前端安装进度展示。全程不要求用户复制安装命令(docs/10 Phase 4)。 + * Email Workers(Worker 部署 + Email Routing + catch-all 规则)不随 OAuth 自动执行, + * 而是在域名列表中按域名单独触发(setupDomainEmail),一个账号只部署一个 Email Worker。 + * + * 所有步骤幂等,重复执行不产生重复资源;账号级步骤状态写入 nodes.provision_steps。 */ import type { Env } from "../env.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 { nowIso } from "../util.ts"; -/** # 成员变量: 安装步骤定义(顺序即展示顺序) **/ +/** # 成员变量: 账号安装步骤定义(顺序即展示顺序) **/ export const PROVISION_STEPS = [ { key: "oauth", label: "Cloudflare OAuth 授权" }, { key: "identity", label: "Node identity 与 credential" }, { key: "discover", label: "Account / Zone 发现" }, - { key: "email_worker", label: "Email Worker 创建/更新" }, - { key: "email_routing", label: "Email Routing 配置" }, - { key: "domains", label: "Domain 创建" }, - { key: "mailboxes", label: "Mailbox 创建" }, + { key: "domains", label: "域名与 catch-all 邮箱同步" }, ] as const; /** # 成员变量: 步骤状态 **/ @@ -38,14 +36,14 @@ export function initialSteps(): Record { - return encryptSecret(env, text); -} - -async function decryptText(env: Env, blob: string): Promise { - return decryptSecret(env, blob); -} - /** # 成员变量: 授权完成后的完整安装流程参数 **/ export interface ProvisionInput { nodeId: string; @@ -147,17 +137,15 @@ export interface ProvisionInput { } /** # 函数 - * OAuth 授权成功后的自动安装主流程(幂等,可重复调用)。 - * 返回是否全部成功;失败时步骤状态里带 error。 + * OAuth 授权成功后的自动安装主流程:发现账号 + 同步全部域名(pending)+ catch-all 邮箱。 + * 幂等,可重复调用;返回是否全部成功;失败时步骤状态里带 error。 */ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> { const cf: CFApi = getCFApi(env, env.DB); - const node = await env.DB.prepare( - "SELECT id, name, ingress_url, ingress_secret_enc FROM nodes WHERE id = ?", - ) + const node = await env.DB.prepare("SELECT id, name FROM nodes WHERE id = ?") .bind(input.nodeId) - .first<{ id: string; name: string; ingress_url: string; ingress_secret_enc: string }>(); - if (!node) return { ok: false, error: "node not found" }; + .first<{ id: string; name: string }>(); + if (!node) return { ok: false, error: "account not found" }; const steps = await loadSteps(env, input.nodeId); const mark = async (key: string, status: StepStatus, error?: string) => { @@ -175,7 +163,7 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ // 1. OAuth 授权 await mark("oauth", "done"); - // 2. Node identity/credential(在 Add Node 与 enrollment 时完成) + // 2. Node identity/credential(在添加账号与 enrollment 时完成) await mark("identity", "done"); // 3. Account/Zone 发现 @@ -185,47 +173,25 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ const account = input.accountId ? accounts.find((a) => a.id === input.accountId) ?? accounts[0] : accounts[0]; - await env.DB.prepare("UPDATE nodes SET cloudflare_account_id = ?, updated_at = ? WHERE id = ?") - .bind(account.id, nowIso(), input.nodeId) + // 自动创建的账号以 Cloudflare 账户名命名 + 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(); const zones = await cf.listZones(input.accessToken, account.id); await mark("discover", "done"); - // 4. Email Worker(幂等上传) - await mark("email_worker", "running"); - const workerName = emailWorkerNameFor(input.nodeId); - const ingressSecret = await decryptText(env, node.ingress_secret_enc); - await cf.putEmailWorker({ - accessToken: input.accessToken, - accountId: account.id, - scriptName: workerName, - ingressUrl: node.ingress_url, - ingressSecret, - }); - await mark("email_worker", "done"); - - // 5+6. 每个 Zone:启用 Email Routing + catch-all -> Worker,并 upsert Domain - await mark("email_routing", "running"); + // 4. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱 + await mark("domains", "running"); for (const zone of zones) { - await cf.enableEmailRouting({ accessToken: input.accessToken, zoneId: zone.id }); - await cf.setCatchAllRule({ - accessToken: input.accessToken, - zoneId: zone.id, - workerName, - }); await upsertDomain(env, { cloudflareAccountId: account.id, nodeId: input.nodeId, name: zone.name, zoneId: zone.id, - workerId: workerName, + 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 = ?") .bind(input.nodeId) .all<{ id: string; name: string }>(); @@ -236,7 +202,7 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ isCatchAll: true, }); } - await mark("mailboxes", "done"); + await mark("domains", "done"); await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?") .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 的步骤状态 */ @@ -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( env: Env, @@ -283,28 +299,35 @@ export async function upsertDomain( nodeId: string; name: string; zoneId: string; - workerId: string; + workerId: string | null; }, ): Promise { 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) - .first<{ id: string }>(); + .first<{ id: string; worker_id: string | null }>(); const now = nowIso(); if (existing) { - await env.DB.prepare( - "UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?", - ) - .bind(d.nodeId, d.zoneId, d.workerId, now, existing.id) - .run(); + // 已配置过 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( + "UPDATE domains SET node_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?", + ) + .bind(d.nodeId, d.zoneId, d.workerId, now, existing.id) + .run(); + } return existing.id; } const id = ids.domain(); await env.DB.prepare( - "INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)", + "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(); return id; } diff --git a/console/src/services/webhook_delivery.ts b/console/src/services/webhook_delivery.ts index 131fde9..610fedf 100644 --- a/console/src/services/webhook_delivery.ts +++ b/console/src/services/webhook_delivery.ts @@ -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 ` 携带(headers 里显式给了 Authorization 则不覆盖), + * 并用于投递签名:signature = HMAC-SHA256(key, `${timestamp}.${rawBody}`) * - payload 按文档 JSON 结构(event=email.received) - * - 签名头:X-WmailHub-Event / Delivery / Timestamp / Signature - * signature = HMAC-SHA256(webhook_secret, `${timestamp}.${rawBody}`) - * - 2xx 成功;5xx/超时重试(指数退避);4xx 记失败并按策略重试到上限 + * - 2xx 成功;失败重试(指数退避);投递行在入队时固化目标配置,重试不跟随后续配置变更 * - endpoint 每次投递前重过 SSRF 校验(静态 + DNS 解析,本地运行时) */ import type { Env } from "../env.ts"; import { hmacSha256Hex, + aesEncrypt, + aesDecrypt, assertWebhookUrlSafe, + ids, type HostResolver, WEBHOOK_HEADERS, WEBHOOK_TIMEOUT_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_RETRY_BASE_MS, } from "../../../shared/src/index.ts"; +import { encryptSecret, decryptSecret, getAesKey, getSetting, setSetting } from "../db.ts"; import { nowIso } from "../util.ts"; +/** # 成员变量: 单级 Webhook 配置(解析结果) **/ +export interface WebhookConfig { + /** 配置来源层级 **/ + targetType: "mailbox" | "domain" | "global"; + /** 目标 URL **/ + url: string; + /** 自定义请求头 **/ + headers: Record; + /** key 明文(签名与 Authorization;可能为空串) **/ + key: string; +} + +/** # 成员变量: settings 表中全局 Webhook 的存储键 **/ +export const GLOBAL_WEBHOOK_SETTING = "global_webhook"; + +/** # 函数 + * 校验并规整自定义请求头:键名限 [A-Za-z0-9-],值转字符串;非法抛错 + * 参数: headers - 前端传入的对象 + * 返回值: 规整后的头(空对象返回 {}) + */ +export function normalizeWebhookHeaders(headers: unknown): Record { + if (headers === undefined || headers === null) return {}; + if (typeof headers !== "object" || Array.isArray(headers)) throw new Error("headers must be an object"); + const out: Record = {}; + for (const [k, v] of Object.entries(headers as Record)) { + 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 { + 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 { + 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(); + 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(); + 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 }; + 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 { + 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; key?: string }, +): Promise { + 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; key?: string }, +): Promise { + 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 { + return key ? aesEncrypt(await getAesKey(env), key) : null; +} + +/** # 函数 + * 解密投递行固化的 key + */ +async function decryptWebhookKey(env: Env, blob: string | null): Promise { + return blob ? await aesDecrypt(await getAesKey(env), blob) : ""; +} + /** # 成员变量: Webhook 投递 payload 结构(docs/07) **/ export function buildWebhookPayload(email: { id: string; @@ -53,30 +237,43 @@ function safeParseArray(s: string | null): string[] { /** # 函数 * 执行一次投递;返回是否成功与错误信息(不修改库) + * 参数: config - 固化在投递行上的目标配置 */ export async function attemptDelivery( - webhook: { id: string; endpoint: string; secret: string }, + config: { url: string; headers: Record; key: string }, deliveryId: string, payload: Record, resolve?: HostResolver, allowPrivateHosts = false, ): 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}` }; const body = JSON.stringify(payload); const timestamp = String(Math.floor(Date.now() / 1000)); - const signature = await hmacSha256Hex(webhook.secret, `${timestamp}.${body}`); + + // 组装请求头:标准头 -> key 的默认 Authorization -> 用户自定义头(可覆盖标准头,但不能去 Content-Type) + // 未配置 key 时无法协商签名密钥,跳过签名头(仅保留事件标识) + const headers: Record = { + "Content-Type": "application/json", + [WEBHOOK_HEADERS.event]: "email.received", + [WEBHOOK_HEADERS.delivery]: deliveryId, + }; + 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(webhook.endpoint, { + const resp = await fetch(config.url, { method: "POST", - headers: { - "Content-Type": "application/json", - [WEBHOOK_HEADERS.event]: "email.received", - [WEBHOOK_HEADERS.delivery]: deliveryId, - [WEBHOOK_HEADERS.timestamp]: timestamp, - [WEBHOOK_HEADERS.signature]: signature, - }, + headers, body, signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS), }); @@ -95,6 +292,23 @@ export function nextRetryIso(attemptCount: number): string { 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 逐个投递并落状态。 * 返回处理统计(供日志与 admin 接口)。 @@ -107,11 +321,10 @@ export async function processDueDeliveries( ): Promise<{ processed: number; success: number; failed: number; exhausted: number }> { const now = nowIso(); const due = await env.DB.prepare( - `SELECT d.id AS delivery_id, d.attempt_count, w.id AS webhook_id, w.endpoint, w.secret, w.enabled, + `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.received_at, e.expires_at FROM webhook_deliveries d - JOIN webhooks w ON w.id = d.webhook_id JOIN emails e ON e.id = d.email_id WHERE d.status IN ('pending', 'failed') AND d.attempt_count < ? @@ -120,22 +333,7 @@ export async function processDueDeliveries( LIMIT ?`, ) .bind(WEBHOOK_MAX_ATTEMPTS, now, limit) - .all<{ - delivery_id: string; - attempt_count: number; - webhook_id: string; - endpoint: string; - secret: string; - enabled: number; - email_id: string; - mailbox_id: string | null; - from_address: string | null; - to_addresses: string; - subject: string | null; - text_body: string | null; - received_at: string; - expires_at: string; - }>(); + .all(); let success = 0; let failed = 0; @@ -149,7 +347,6 @@ export async function processDueDeliveries( exhausted++; continue; } - if (!row.enabled) continue; const mailbox = row.mailbox_id ? ( @@ -168,7 +365,7 @@ export async function processDueDeliveries( receivedAt: row.received_at, }); 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, payload, resolve, @@ -200,3 +397,32 @@ export async function processDueDeliveries( } return { processed: due.results.length, success, failed, exhausted }; } + +/** # 函数 + * 入队:为邮件按三级解析结果创建一条投递记录(未配置任何 Webhook 则不入队) + * 返回值: 命中的层级或 null + */ +export async function enqueueDeliveryForEmail( + env: Env, + emailId: string, + mailboxId: string | null, +): Promise { + 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; +} diff --git a/migrations/0002_accounts_webhooks.sql b/migrations/0002_accounts_webhooks.sql new file mode 100644 index 0000000..60f4a54 --- /dev/null +++ b/migrations/0002_accounts_webhooks.sql @@ -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); diff --git a/package.json b/package.json index 9b86f3c..d2857a2 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "wmailhub", - "version": "1.0.0", + "version": "1.1.0", "description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console + Node + Email Worker)", "private": true, "type": "module", "scripts": { "dev": "bun run scripts/dev.ts", - "build": "bun run scripts/build-frontend.ts", + "build": "vite build", "test": "bun test", "typecheck": "tsc --noEmit" }, @@ -22,9 +22,11 @@ "devDependencies": { "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.1.1", "bun-types": "^1.2.0", "playwright-core": "^1.63.0", - "typescript": "^5.9.0" + "typescript": "^5.9.0", + "vite": "^8.3.1" }, "engines": { "bun": ">=1.2.0" diff --git a/scripts/build-frontend.ts b/scripts/build-frontend.ts deleted file mode 100644 index 0a8ba14..0000000 --- a/scripts/build-frontend.ts +++ /dev/null @@ -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"); diff --git a/scripts/dev.ts b/scripts/dev.ts index 47ba988..f31549e 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -4,9 +4,9 @@ * bun run dev -- --reset # 清空本地数据后全新安装 * * 流程: - * 1. 构建前端 + * 1. 构建前端(vite build) * 2. 启动 Console(:18787,自动迁移 + admin/admin 种子) - * 3. 确保存在本地 Node(无则创建)并完成 sim 模式 OAuth 自动安装(幂等) + * 3. 确保存在本地账号(无则 OAuth sim 自动创建账号 + 同步域名),并逐域名配置 Email Workers * 4. 启动 Node(:18788,enrollment 换取身份,config.json 0600 落盘) * 5. 启动 Email Worker(:18789,转发到 Node) * 6. Ctrl-C 一起退出 @@ -79,54 +79,51 @@ if (!loginResp.ok) throw new Error("admin 登录失败"); const cookie = (loginResp.headers.get("set-cookie") ?? "").split(";")[0]; const authHeaders = { Cookie: cookie, "Content-Type": "application/json" }; -// 3. 确保本地 Node 存在 -const nodesResp = await fetch(`${BASE}/api/nodes`, { headers: authHeaders }); -const existing = ((await nodesResp.json()) as { items: { id: string; name: string }[] }).items; +// 3. 确保本地账号存在(无则走 OAuth 自动创建);拿到 ingress secret 与 enrollment token +const accountsResp = await fetch(`${BASE}/api/accounts`, { headers: authHeaders }); +const existing = ((await accountsResp.json()) as { items: { id: string; name: string }[] }).items; let nodeId: string; let ingressSecret: string; let enrollmentToken: string | null = null; if (existing.length > 0) { nodeId = existing[0].id; - ingressSecret = ((await (await fetch(`${BASE}/api/nodes/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret; + ingressSecret = ((await (await fetch(`${BASE}/api/accounts/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret; // 无论 config.json 是否存在,都发一个新 token:有身份的 Node 会忽略它,无身份的用它 enrollment const rotated = (await ( - await fetch(`${BASE}/api/nodes/${nodeId}/rotate`, { method: "POST", headers: authHeaders }) + await fetch(`${BASE}/api/accounts/${nodeId}/rotate`, { method: "POST", headers: authHeaders }) ).json()) as { enrollment_token: string }; enrollmentToken = rotated.enrollment_token; - console.log(`[dev] 复用既有节点 ${nodeId}`); + console.log(`[dev] 复用既有账号 ${nodeId}`); } else { - const created = (await ( - await fetch(`${BASE}/api/nodes`, { - method: "POST", - headers: authHeaders, - body: JSON.stringify({ name: "Local Node", ingress_url: `http://127.0.0.1:${NODE_PORT}/ingress/email` }), - }) - ).json()) as { node_id: string; enrollment_token: string; ingress_secret: string }; - nodeId = created.node_id; - ingressSecret = created.ingress_secret; - enrollmentToken = created.enrollment_token; - console.log(`[dev] 已创建节点 ${nodeId}`); -} - -// 4. sim OAuth 自动安装(幂等;节点已 online 时跳过) -const nodeDetail = (await (await fetch(`${BASE}/api/nodes/${nodeId}`, { headers: authHeaders })).json()) as { - node: { status: string }; - steps: Record; -}; -if (nodeDetail.node.status === "pending_oauth") { - const startResp = await fetch(`${BASE}/api/oauth/cloudflare/start?node_id=${nodeId}`, { headers: authHeaders }); + // 添加账号:OAuth start 不带 node_id,服务端自动创建账号 + const startResp = await fetch(`${BASE}/api/oauth/cloudflare/start`, { headers: authHeaders }); const { state } = (await startResp.json()) as { state: string }; const approve = await fetch(`${BASE}/api/oauth/cloudflare/sim/approve`, { method: "POST", headers: authHeaders, body: JSON.stringify({ state }), }); - const approveRes = (await approve.json()) as { ok: boolean; error?: string }; - if (!approveRes.ok) throw new Error(`自动安装失败:${approveRes.error}`); - console.log("[dev] Cloudflare OAuth(sim)自动安装完成:Email Worker / Routing / Domain / Mailbox 就绪"); -} else { - console.log(`[dev] 节点已安装(status=${nodeDetail.node.status}),跳过安装`); + const approveRes = (await approve.json()) as { ok: boolean; error?: string; node_id: string }; + if (!approveRes.ok) throw new Error(`自动创建账号失败:${approveRes.error}`); + nodeId = approveRes.node_id; + ingressSecret = ((await (await fetch(`${BASE}/api/accounts/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret; + 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 已有身份) diff --git a/tests/api-mcp.test.ts b/tests/api-mcp.test.ts index eda5559..6cdd004 100644 --- a/tests/api-mcp.test.ts +++ b/tests/api-mcp.test.ts @@ -147,7 +147,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => { expect(resp.status).toBe(403); }); - test("initialize + tools/list 返回六个工具与 serverInfo", async () => { + test("initialize + tools/list 返回五个工具与 serverInfo", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); const mcpKey = await createKey(t, cookie, "mcp"); @@ -164,7 +164,6 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => { "get_email", "create_mailbox", "delete_mailbox", - "list_webhooks", ]); }); diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 3ff0202..018bf88 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -27,7 +27,7 @@ describe("用户认证(默认 admin/admin)", () => { test("无会话访问受保护接口返回 401", async () => { 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); }); diff --git a/tests/fixtures/e2e-receiver.ts b/tests/fixtures/e2e-receiver.ts new file mode 100644 index 0000000..73b3cd6 --- /dev/null +++ b/tests/fixtures/e2e-receiver.ts @@ -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; + 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"); diff --git a/tests/helpers.ts b/tests/helpers.ts index 0e2811f..ff6a006 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -15,8 +15,14 @@ import { encryptSecret } from "../console/src/db.ts"; import { hashPassword, sha256Hex, signNodeRequest } from "../shared/src/index.ts"; import { ids } from "../shared/src/index.ts"; -/** # 成员变量: 迁移 SQL(一次读取,多处使用) **/ -export const MIGRATION_SQL = await Bun.file(new URL("../migrations/0001_init.sql", import.meta.url)).text(); +/** # 成员变量: 迁移 SQL(按文件名顺序全部应用,一次读取) **/ +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 附件) **/ export const TEST_MAIL = `From: Alice \r diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 19fd566..f1d4d85 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -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 { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts"; 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 cookie = await loginCookie(t); - // Add Node - const created = (await ( - await authedFetch(t, cookie, "http://localhost/api/nodes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "Sim Node", ingress_url: "http://127.0.0.1:18788/ingress/email" }), - }) - ).json()) as { node_id: string; enrollment_token: string }; - expect(created.enrollment_token.startsWith("wenr_")).toBe(true); - - // OAuth start + // 添加账号:OAuth start 不带 node_id(服务端自动创建账号) 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 }; expect(start.url).toContain("/oauth/sim/authorize?state="); @@ -30,31 +20,56 @@ describe("Cloudflare OAuth(sim 模式)", () => { headers: { "Content-Type": "application/json" }, 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); - // 安装结果 + // 安装结果:发现 + 域名同步完成 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 { - node: { status: string; cloudflare_account_id: string | null }; + account: { status: string; cloudflare_account_id: string | null; name: string }; steps: Record; domains: { name: string }[]; }; - expect(detail.node.status).toBe("offline"); // 等待 Node 心跳上线 - expect(detail.node.cloudflare_account_id).toBe("sim-account-0001"); - for (const key of ["oauth", "identity", "discover", "email_worker", "email_routing", "domains", "mailboxes"]) { + expect(detail.account.status).toBe("offline"); // 等待 Node 心跳上线 + expect(detail.account.cloudflare_account_id).toBe("sim-account-0001"); + 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.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 }>(); 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 kinds = sim.results.map((r) => r.kind); + expect(kinds).toContain("zone"); expect(kinds).toContain("email_worker"); expect(kinds).toContain("routing_rule"); @@ -66,18 +81,11 @@ describe("Cloudflare OAuth(sim 模式)", () => { 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 cookie = await loginCookie(t); - const created = (await ( - await authedFetch(t, cookie, "http://localhost/api/nodes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "n" }), - }) - ).json()) as { node_id: string }; const { state } = (await ( - await authedFetch(t, cookie, `http://localhost/api/oauth/cloudflare/start?node_id=${created.node_id}`) + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start") ).json()) as { state: string }; 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 () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); - const created = (await ( - await authedFetch(t, cookie, "http://localhost/api/nodes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "n2" }), - }) - ).json()) as { node_id: string; enrollment_token: string }; + // 自动创建账号后,用 rotate 取得 enrollment token + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start"); + const accounts = (await ( + await authedFetch(t, cookie, "http://localhost/api/accounts") + ).json()) as { items: { id: string }[] }; + const accountId = accounts.items[0].id; + 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 ( await t.app.fetch( new Request("http://localhost/api/internal/enroll", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enrollment_token: created.enrollment_token }), + body: JSON.stringify({ enrollment_token: rotated.enrollment_token }), }), t.env, ) ).json()) as { node_id: string; credential_secret: string; ingress_secret: string }; - expect(enroll.node_id).toBe(created.node_id); + expect(enroll.node_id).toBe(accountId); expect(enroll.credential_secret.startsWith("wns_")).toBe(true); // 库中不存明文 secret const cred = await t.env.DB.prepare( "SELECT secret_hash, secret_enc FROM node_credentials WHERE node_id = ? AND status='active'", ) - .bind(created.node_id) + .bind(accountId) .first<{ secret_hash: string; secret_enc: string }>(); expect(cred?.secret_hash).not.toBe(enroll.credential_secret); expect(cred?.secret_enc).not.toContain(enroll.credential_secret); diff --git a/tests/pipeline.test.ts b/tests/pipeline.test.ts index 181bb0b..70b1054 100644 --- a/tests/pipeline.test.ts +++ b/tests/pipeline.test.ts @@ -165,7 +165,7 @@ describe("TTL 清理与 orphan", () => { ) .run(); await t.env.DB.prepare( - "INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, created_at) VALUES ('dlv_orphan', 'whk_gone', 'msg_gone', 'pending', 0, ?)", + "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) .run(); diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 0000000..33d398b --- /dev/null +++ b/tests/settings.test.ts @@ -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 } }; + 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); + }); +}); diff --git a/tests/ui-smoke.ts b/tests/ui-smoke.ts index 870ea75..273a35d 100644 --- a/tests/ui-smoke.ts +++ b/tests/ui-smoke.ts @@ -1,6 +1,6 @@ /** * UI 冒烟测试(Playwright headless): - * 登录 admin/admin → 概览/节点/域名/邮箱/邮件/Webhooks/Keys 各页渲染 → 截图。 + * 登录 admin/admin → 概览/账号/域名/邮箱/邮件 各页渲染 → 设置模态窗口 → 生成 Key → 截图。 * bun tests/ui-smoke.ts */ @@ -42,45 +42,62 @@ await page.click("button[type=submit]"); await page.waitForURL(`${BASE}/`, { timeout: 8000 }); await page.waitForSelector("text=概览", { timeout: 8000 }); 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.screenshot({ path: `${SHOT_DIR}/02-dashboard.png` }); // 3. 各页面导航渲染 const pages = [ - { path: "/nodes", expect: "节点管理", shot: "03-nodes.png" }, - { path: "/domains", expect: "域名管理", shot: "04-domains.png" }, - { path: "/mailboxes", expect: "邮箱管理", shot: "05-mailboxes.png" }, + { path: "/accounts", expect: "账号", shot: "03-accounts.png" }, + { path: "/domains", expect: "域名", shot: "04-domains.png" }, + { path: "/mailboxes", expect: "邮箱", shot: "05-mailboxes.png" }, { path: "/emails", expect: "邮件", shot: "06-emails.png" }, - { path: "/webhooks", expect: "Webhooks", shot: "07-webhooks.png" }, - { path: "/keys", expect: "API / MCP Keys", shot: "08-keys.png" }, ]; for (const p of pages) { await page.goto(`${BASE}${p.path}`, { waitUntil: "domcontentloaded" }); await page.waitForSelector(`text=${p.expect}`, { timeout: 8000 }); await page.waitForTimeout(500); const hasTable = (await page.locator(".ant-table").count()) > 0; - check(`页面 ${p.path} 渲染${hasTable ? "(含表格)" : ""}`, true); + const hasSearch = (await page.locator("input[placeholder*='搜索']").count()) > 0; + check(`页面 ${p.path} 渲染${hasTable ? "(含表格)" : ""}${hasSearch ? "(含搜索框)" : ""}`, hasTable); 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.waitForSelector(".ant-table-row", { timeout: 8000 }); await page.locator(".ant-table-row").first().click(); await page.waitForSelector(".ant-drawer-open", { timeout: 8000 }); const drawerText = await page.locator(".ant-drawer-title").textContent(); -check("邮件详情抽屉打开", Boolean(drawerText && drawerText.includes("端到端"))); -await page.screenshot({ path: `${SHOT_DIR}/09-email-detail.png` }); +check("邮件详情抽屉打开", Boolean(drawerText && drawerText.includes("WmailHub"))); +await page.screenshot({ path: `${SHOT_DIR}/08-email-detail.png` }); +await page.keyboard.press("Escape"); -// 5. 生成 API Key 流程 -await page.goto(`${BASE}/keys`, { waitUntil: "domcontentloaded" }); -await page.waitForSelector("text=生成 Key", { timeout: 8000 }); -await page.click("button:has-text('生成 Key')"); -await page.click(".ant-dropdown-menu-item:has-text('生成 API Key')"); +// 6. 设置模态窗口:基本设置 + 生成 MCP Key +await page.locator("button[aria-label='设置']").click(); +await page.waitForSelector(".ant-modal:has-text('全局 Webhook')", { timeout: 8000 }); +check("设置模态窗口打开(全局 Webhook / Ingress / 密码)", true); +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 }); -const keyText = await page.locator(".ant-modal .mono").first().textContent(); -check("生成 API Key 弹窗(wmh_ 32 位)", Boolean(keyText && keyText.startsWith("wmh_") && keyText.trim().length === 32), keyText?.trim()); -await page.screenshot({ path: `${SHOT_DIR}/10-key-created.png` }); +const keyText = await page.locator(".ant-modal:has-text('仅显示一次') .mono").first().textContent(); +check("生成 MCP Key 弹窗(wmh_ 32 位)", Boolean(keyText && keyText.trim().startsWith("wmh_") && keyText.trim().length === 32), keyText?.trim()); +await page.screenshot({ path: `${SHOT_DIR}/10-mcp-key-created.png` }); await browser.close(); diff --git a/tests/webhook.test.ts b/tests/webhook.test.ts index 4fecfa2..94ba007 100644 --- a/tests/webhook.test.ts +++ b/tests/webhook.test.ts @@ -1,7 +1,7 @@ -/** Webhook 测试:SSRF 校验、投递签名、重试与退避、失败状态、手动测试投递 */ +/** Webhook 测试(v1.1.0 三级模型):SSRF 校验、三级优先级、签名/key/自定义头、重试与退避 */ import { describe, expect, test } from "bun:test"; import { ingestEmail } from "../console/src/services/email_ingest.ts"; -import { attemptDelivery, processDueDeliveries } from "../console/src/services/webhook_delivery.ts"; +import { attemptDelivery, processDueDeliveries, resolveWebhookConfig } from "../console/src/services/webhook_delivery.ts"; import { hmacSha256Hex } from "../shared/src/index.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) }; } -describe("Webhook", () => { - test("创建 webhook 时拒绝私网/localhost endpoint(SSRF)", async () => { +/** # 函数 + * 直接给域名/邮箱行写 webhook 配置(key 以空占位,投递 key 传 "" 即可) + */ +async function setScopedWebhook( + t: Awaited>, + table: "mailboxes" | "domains", + id: string, + url: string | null, + headers: Record = {}, +) { + 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(); // SSRF 防护在"未开启本地联调开关"时生效(生产默认态) delete (t.env as { WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string }).WMAILHUB_ALLOW_PRIVATE_WEBHOOKS; @@ -35,57 +52,54 @@ describe("Webhook", () => { "http://[::1]/hook", "ftp://example.com/x", ]) { - const resp = await authedFetch(t, cookie, "http://localhost/api/webhooks", { - method: "POST", + const resp = await authedFetch(t, cookie, "http://localhost/api/settings", { + method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "x", endpoint }), + body: JSON.stringify({ webhook: { url: endpoint } }), }); 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 () => { - 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 () => { + test("全局 Webhook 投递:key 走 Authorization,签名与自定义头正确", async () => { const t = await makeTestEnv(); const recv = startReceiver("ok"); - const now = new Date().toISOString(); - await t.env.DB.prepare( - "INSERT INTO webhooks (id, name, endpoint, secret, enabled, created_at, updated_at) VALUES ('whk1', 'w', ?, 'whsec_test', 1, ?, ?)", - ) - .bind(recv.url, now, now) - .run(); + const cookie = await loginCookie(t); + const save = await authedFetch(t, cookie, "http://localhost/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + webhook: { url: recv.url, headers: { "X-Custom": "wmailhub" }, key: "wmh_global_key_1234567890" }, + }), + }); + expect(save.status).toBe(200); + const email = await ingestEmail(t.env, { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", nodeId: t.nodeId, }); - const dlv = await t.env.DB.prepare("SELECT id FROM webhook_deliveries").first<{ id: string }>(); - expect(dlv).not.toBeNull(); + const dlv = await t.env.DB.prepare("SELECT id, target_type FROM webhook_deliveries").first<{ + id: string; + target_type: string; + }>(); + expect(dlv?.target_type).toBe("global"); + const stats = await processDueDeliveries(t.env, undefined, 20, true); expect(stats.success).toBe(1); - const row = await t.env.DB.prepare("SELECT status, delivered_at FROM webhook_deliveries").first<{ - status: string; - delivered_at: string | null; - }>(); - expect(row?.status).toBe("success"); - // 验证签名 const req = recv.requests[0]; + 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 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-Event") ?? null).toBe("email.received"); expect(req.headers.get("X-WmailHub-Delivery") ?? null).toBe(dlv?.id ?? null); @@ -96,77 +110,114 @@ describe("Webhook", () => { recv.stop(); }); - test("5xx 失败进入重试队列,退避时间递增,超过上限 exhausted", async () => { + test("三级优先级:邮箱 > 域名 > 全局,命中即止;逐级回退", async () => { 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( - "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(); - // msg_gone 不存在(触发 email expired 分支前先看 join):join 不到直接跳过 — 改用真实邮件 - await t.env.DB.prepare("DELETE FROM webhook_deliveries WHERE id='d1'").run(); - // 建真实 webhook 行(endpoint 不可达),投递 join 需要 - await t.env.DB.prepare( - "INSERT INTO webhooks (id, name, endpoint, secret, enabled, created_at, updated_at) VALUES ('whk_gone', 'gone', 'http://127.0.0.1:1/hook', 's', 1, ?, ?)", - ) - .bind(now, now) - .run(); - const email = await ingestEmail(t.env, { + + await ingestEmail(t.env, { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", nodeId: t.nodeId, }); - await t.env.DB.prepare( - "INSERT INTO webhook_deliveries (id, webhook_id, email_id, status, attempt_count, created_at) VALUES ('d2', 'whk_gone', ?, 'pending', 0, ?)", - ) - .bind(email.id, now) - .run(); - // 直连 attemptDelivery 到一个 5xx 地址(fetch 会失败) + await processDueDeliveries(t.env, undefined, 20, true); + expect(mailboxRecv.requests.length).toBe(1); + expect(domainRecv.requests.length).toBe(0); + expect(globalRecv.requests.length).toBe(0); + const level1 = await t.env.DB.prepare("SELECT target_type FROM webhook_deliveries ORDER BY created_at DESC LIMIT 1").first<{ target_type: string }>(); + 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( - { id: "whk_gone", endpoint: "http://127.0.0.1:1/hook", secret: "s" }, - "d2", + { url: "http://127.0.0.1:1/hook", headers: {}, key: "" }, + "d_test", { event: "email.received" }, undefined, true, ); 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"); - 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++) { const stats = await processDueDeliveries(t.env, undefined, 20, true); if (stats.processed === 0) break; - attempts++; - await t.env.DB.prepare("UPDATE webhook_deliveries SET next_retry_at = ? WHERE id='d2'") - .bind(new Date().toISOString()) + await t.env.DB.prepare("UPDATE webhook_deliveries SET next_retry_at = ? WHERE id = ?") + .bind(new Date().toISOString(), row0!.id) .run(); } - const row = await t.env.DB.prepare("SELECT status, attempt_count FROM webhook_deliveries WHERE id='d2'").first<{ - status: string; - attempt_count: number; - }>(); - // 目标不可达(端口 1),每次都失败,最终 exhausted - expect(row?.status).toBe("exhausted"); - expect(row?.attempt_count).toBe(attempts); - }); - - test("手动 test 投递走同一条签名链路", async () => { - const t = await makeTestEnv(); - const recv = startReceiver("ok"); - const cookie = await loginCookie(t); - const create = await authedFetch(t, cookie, "http://localhost/api/webhooks", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "t", endpoint: recv.url }), - }); - const { id } = (await create.json()) as { id: string }; - const testResp = await authedFetch(t, cookie, `http://localhost/api/webhooks/${id}/test`, { method: "POST" }); - const data = (await testResp.json()) as { ok: boolean }; - expect(data.ok).toBe(true); - expect(recv.requests.length).toBeGreaterThanOrEqual(1); - recv.stop(); + const final = await t.env.DB.prepare("SELECT status, attempt_count FROM webhook_deliveries WHERE id = ?") + .bind(row0!.id) + .first<{ status: string; attempt_count: number }>(); + expect(final?.status).toBe("exhausted"); + expect(final?.attempt_count).toBe(WEBHOOK_MAX_ATTEMPTS); }); }); diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..9dfb52c --- /dev/null +++ b/vite.config.ts @@ -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), + }, +});